iOS アプリの UI でこれだけはおさえたい細部のインタラクション3つ

Holiday 事業室の多田です。先日 Elasticsearch の記事を書いた内藤と共に Holiday ( https://haveagood.holiday ) の開発を行っています。

Holiday は、去年9月に Web 版をリリースしましたが、よりおでかけを楽しくするために今年3月に iPhone アプリをリリースしました(ダウンロードはこちら)。

アプリの開発過程ではコンセプトや仮説を立て、その検証や実現のために作っては壊すことを何度も繰り返し行いますが、実現したい価値を提供するためには、出来上がったプロダクトの細部のインタラクションも重要になってきます。細かい部分に気を配り使い心地を良くしてこそ、本当に提供したい価値をまっすぐに届けることができるためです。逆に言えば、最後の最後で細かい部分がちゃんとしていないばかりにそれまでの過程が無駄になったらもったいないですよね。

今回はそのような細部のインタラクションの中でも、iOS アプリで幅広く使われている基本的なインタラクションを3つ、Holiday の iPhone アプリでの例とともに紹介します。

ログインフォームでのキーボードリターンの挙動

f:id:tdksk:20150318170513g:plain

Holiday のログイン画面は、メールアドレスとパスワードを入力するフィールドとログイン処理を実行するボタンが存在する一般的なものです。

このようなログインフォームの使いやすさを考えてみます。すると、デフォルトの挙動ではテキストをキーボードで入力→画面の他の部分をタップ→またキーボードで入力…としなくてはならず、操作する場所があちこち変わって不便だなということに気づきます。

この問題を解決するために、多くの iOS アプリではキーボードのリターン部分の挙動をフィールドによって適切なものに変えています。具体的には、次にフィールドがある場合にはそのフィールドに移動し、最後のフィールドではサブミット処理を行うというものです。

そのような処理は以下のような実装で実現できます。

// MARK: - UITextFieldDelegate

func textFieldShouldReturn(textField: UITextField) -> Bool {
    if textField === emailField {
        // 次のフィールドに移動
        passwordField?.becomeFirstResponder()
    } else if textField === passwordField {
        // ログイン処理を実行
        login()
    } else {
        textField.resignFirstResponder()
    }

    return true
}

また、処理だけでなくキーボードのリターン部分の見た目も変えることができます。Interface Builder 上で Return KeyNextGo などに変えると良いです。

f:id:tdksk:20150318170553p:plain

キーボードをスクロール時に隠す

f:id:tdksk:20150318170602g:plain

Holiday のおでかけプランをさがす画面では、検索バーにフォーカスがあたった時にキーボードが出現するだけでなく検索履歴もリストで表示するようなものになっています。このように一つの画面に検索バーとリストを表示しているようなアプリは多く存在します(Facebook, Instagram, Twitter など)。

普通にテキスト入力する場合はずっとキーボードを出しておけば良いのですが、既に表示されているリストのほうに注目している場合はどうでしょうか。この時はテキスト入力をしたいとは思っていないため、キーボードを隠したほうがリストの一覧性が上がって良さそうです。ということで、リストのほうを見ようとスクロールする時にはキーボードは隠しましょう。

// MARK: - UIScrollViewDelegate

func scrollViewWillBeginDragging(scrollView: UIScrollView) {
    view.endEditing(true)
}

追記

記事公開後に教えていただいたのですが、iOS 7 からは UIScrollView に keyboardDismissMode というプロパティが追加され、Interface Builder から Dissmiss on drag を指定するだけで同じ挙動が実現できるようになっていました。こちらのほうが手軽で便利ですね。

f:id:tdksk:20150319115927p:plain

ボタンタップ時、通信処理が完了する前にフィードバックを返す

f:id:tdksk:20150318170650g:plain

サーバと通信するアプリでは、通信が伴う処理のフィードバックをどのタイミングでどのように行うかという問題が常につきまといます。

例えば Holiday ではおでかけプランをお気に入りに追加することができるのですが、そのデータは全てサーバ上に保存しています。そのため、お気に入りに追加・削除する処理が正常に行われたかどうかをチェックして、サーバ側のデータの状態とクライアント側での表示が矛盾しないようにする必要があります。その際の確実な方法は、リクエストを送ってレスポンスが返ってくるまでは読み込み中であることを示し、レスポンスが返ってきたらその状態を画面に反映させるという方法です。処理に時間がかかる場合や、その間ユーザの行動を妨げても問題がない場合はそれでも良いのですが、お気に入りなどのライトな行動においてはその後の行動を妨げず、かつすぐにフィードバックを返すことがストレスのない体験に繋がります。

具体的には、ボタンが押されたタイミングでまず先にボタンをお気に入り追加済みの状態(黄色)に変えてしまいます。またこれだけだとフィードバックとして弱いので、バウンスするアニメーションも同時に行うようにしています。その後レスポンスが返ってきたら正しい状態にする(ほとんどの場合は成功しているのでそのまま)という処理を入れています。レスポンスが返ってくるまでは数 m 秒ですが、それを待つかすぐにフィードバックを返すかで大きく使い心地に影響します。

このような実装を簡略化したコードは下記の通りです。

@IBAction func bookmarkButtonTapped(sender: AnyObject) {
    // モデルの状態を変更
    plan.toggleBookmark()

    // ボタンの選択状態を先に変えるフィードバック
    reloadBookmarkButton() // bookmarkButton.selected = plan.bookmarked

    // アニメーションによるフィードバック
    bookmarkButton.doBounceAnimation()

    // 非同期で POST/DELETE リクエストするメソッド
    updatePlanBookmark(
        plan: plan,
        completion: { (error: NSError?) in
            if error {
                // リクエスト失敗時はモデルの状態を元に戻し、ボタンの状態に反映
                plan.toggleBookmark()
                reloadBookmarkButton()
            }
        }
    )
}

ちなみに、バウンスアニメーションは下記のようなパラメータで行っています。

func doBounceAnimation() {
    UIView.animateWithDuration(
        0.05,
        animations: { () -> Void in
            self.transform = CGAffineTransformMakeScale(1.4, 1.4)
        },
        completion: { (Bool) -> Void in
            UIView.animateWithDuration(
                0.6,
                delay: 0.0,
                usingSpringWithDamping: 0.3,
                initialSpringVelocity: 0.0,
                options: .CurveLinear,
                animations: { () -> Void in
                    self.transform = CGAffineTransformMakeScale(1.0, 1.0)
                },
                completion: { (Bool) -> Void in
                    self.transform = CGAffineTransformIdentity
                }
            )
        }
    )
}

最後に

今回は iOS の UI の細部のインタラクションの中でも特に基本的な3つの例を紹介しました。実際のアプリではそれぞれの機能に応じた様々なインタラクションを考える必要がありますが、まず基本的な部分をおろそかにしてはいけません。基本的な細かい部分の使いやすさが、アプリ全体の使い心地に大きく影響し、実現したい価値提供に繋がるのだと思います。

なお、Holiday ではプロダクトの細部にまでこだわりたいエンジニアを募集しています。興味をお持ちいただけた方はぜひご応募ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/