Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

気持ちいいアプリ体験を提供する for iOS

こんにちは、グノシー事業部でiOSエンジニアをしているsyouit523です。この記事は Gunosy Advent Calendar 2019 10日目の記事です。 昨日の記事は akinkさんの広義のQuality向上のためにQAメンバーが大事にしていることでした。

はじめに

今年から新卒で入社しiOSエンジニアとして働いています。そこで今回は、私が普段から心がけている気持ちいいアプリ体験を提供するということについて、導入事例を交えてご紹介します。

私は、ユーザー体験の向上は非常に重要であると考えています。

主にグノスポでの導入事例をご紹介します。 gunosy-sports.com

parallax viewの導入

まずはじめはparallax viewの導入です。フロントエンド界隈では馴染み深い用語かもしれません。

parallax viewの導入で、スクロールに追従したアニメーションの付加でき滑らかに動作する印象を与えることができると考えています。

parallax viewを用いトップの画像サイズが変化していきます。

(Gifアニメーションなので色味が少し違いますがご了承ください)

parallax viewの導入例

上下スクロールに伴ってトップの画像が伸縮しているのがおわかりいただけたでしょうか?

また、下にスクロールした際にコンテンツ部分だけスクロールされるようになっています。これは、imageViewのcontentModeを scaleAspectFill に指定することによって実現しています。

詳細に比較できるよう、通常のスクロールも掲載します。

通常の実装例

通常の場合と比較して、行っていることは下記のとおりです。

  • スクロールに追従して画像が伸縮
  • スクロールに追従して画像にブラーをかける

たったこれだけで印象が違うのは面白いですね。

では具体的にどのように実装したか簡単にご紹介します。

parallax viewの実装

前提として、RxSwift, RxCocoa, EasyPeasyのライブラリを使用しておりますのでご了承ください。

EasyPeasyはコードレイアウトを楽にするために使用しています。条件分岐でレイアウトを付け加えたいときに非常に役立ちます。そのTipsも紹介しているのでご覧ください。

ここでは通常の実装例(parallaxなし)の状態から、parallaxの導入例をご紹介します。

まずこの画面はUITableViewを用いて描画しており、トップのcellにimageViewが存在します。 そこで、cell内のimageViewを削除し、トップのcellのbackground colorをclearに指定します。 これでcellの変更は終了です。

次にtableViewのbackgroundViewを下記のように設定しimageViewを描画します。

let backgroundView = UIView(frame: tableView.frame)
backgroundView.addSubview(headerImageView)
tableView.backgroundView = backgroundView

// headerImageViewの制約追加
tableView
    .rx
    .willDisplayCell
    .asDriver()
    .filter {
         return $0.indexPath == IndexPath(row: 0, section: 0)
    }
    .drive(onNext: { [weak self] (cell, indexPath) in
        self?.headerImageView.contentMode = .scaleAspectFill
        self?.headerImageView.easy.layout(
            Top(),
            Bottom().to(cell, .bottom).with(.high).when({ [weak self] _ -> Bool in
                guard let visibleRows = self?.tableView.indexPathsForVisibleRows else { return true }
                 return visibleRows.contains(IndexPath(row: 0, section: 0))
            }),
            Height(>=0),
            CenterX(),
            Width(>=0).like(cell, .width).with(.high)
        )
    })
    .disposed(by: disposeBag)

制約追加の部分では、トップのcellのwillDisplayCellでトップのcellに対し制約を設定しています。

            Bottom().to(cell, .bottom).with(.high).when({ [weak self] _ -> Bool in
                guard let visibleRows = self?.tableView.indexPathsForVisibleRows else { return true }
                 return visibleRows.contains(IndexPath(row: 0, section: 0))
            }

この処理で、条件分岐でbottomの制約を変更するようにしています。

この状態ではスクロールに追従してブラーのalphaが変更されません。 なのでスクロールに追従するよう指定してあげます。

override func viewDidLoad() {
tableView
    .rx
    .contentOffset
    .bind(to: parallaxAnimation())
    .disposed(by: disposeBag)
}

private func parallaxAnimation() -> Binder<CGPoint> {
        return Binder(self) { vc, offset in
            let yOffset = offset.y + vc.view.safeAreaInsets.top
            let blurAlpha = abs(yOffset) * 0.005
            vc.headerImageView.blurAlpha = blurAlpha
            vc.headerImageView.easy.reload()
        }
    }
}

これでTableViewのcontentOffsetに追従してimageViewのbluer alphaが変更されるようになりました。

また、 parallaxAnimation() の最後に vc.headerImageView.easy.reload() を行っています。これにより条件式を再評価し、bottomの制約を付け替えています。

cellの更新アニメーション

グノスポでは極力TableView, CollectionViewのreloadDataは呼ばないよう心がけています。 これはパフォーマンスとユーザー体験の向上のためです。

reloadDataは全てのcellに対して更新処理が発生するので、再描画が必要なcellだけに描画処理を行うのが好ましいと考えています。

それでは導入例をご覧ください。

cellの更新アニメーション導入例

このように、全要素が一気に描画されず、各要素がフェードインして描画されます。

これにはDifferenceKitを使用して差分更新を行っています。 DifferenceKitを導入すればこれと同様のアニメーションが実現可能です。

cellの更新にアニメーションを用いることでどの要素が変化したのかユーザーが認知しやすくなります。

グノスポには、マイチームに登録しているチームの試合が試合中の場合リアルタイムに戦況がテキスト配信される機能があります。 特にそこで差分更新を用いたcellの更新アニメーションは本領を発揮しました。約1分間隔でサーバー上でテキストが更新され、tableViewの更新が発生します。 アニメーションすることで、テキストが更新されたことを認知しやすくなりました。

最後に

みなさんがよく使用しているアプリは何でしょうか。 私はTwitter, Facebook, LINEをメインに使用しています。おそらく日本における多くのiOSユーザーが同様の使い方だと感じています。 ならば、それらと同程度のユーザー体験を与えなければ(使い勝手が悪ければ)、ユーザーは定着しづらいと感じます。

今後も良いユーザー体験を提供できるようプロダクトを改善していきます!

明日はid:mgi166 さんの記事です。おたのしみに!