Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

ローディング画面をお洒落にする SkeletonView の使い方とハマったポイントまとめ

こんにちは! iOS アプリエンジニアの髙橋佑一朗です!

今回は Sansan アプリ内で使用している Shimmer (シマー) を簡単に表示できるようになる SkeletonView というライブラリについてまとめていきたいと思います。 導入や使い方については公式の README が充実していますし、他にも以下のような記事があるので、ここでは、実際の作業でハマったところやどう対処したかについて書いていきます!

github.com

Shimmer って?

もう知らない方も少ないと思いますが軽く説明を。
Shimmer は Facebook や YouTube などでよく見かけるロード中であることを表す表示のことです。
一般的には表示されるコンテンツとほぼ同じ配置でグレーの View が表示され、一定間隔でキラリと光っているようなアニメーションをするものが多い印象です。

f:id:chaaaaanu:20191024144222p:plain
YouTube の Shimmer

f:id:chaaaaanu:20191024144536p:plain
こちらは Facebook の Shimmer

インジケーターのようなローディングの表示方法に比べ

  1. コンテンツの配置に合わせて View が表示される
  2. 表示された View が右から左(逆方向でも)にアニメーションする

という特性により ユーザに対してコンテンツ表示の準備をしている ということをよりわかりやすく伝えられるというメリットがあります。
場合によりけりですが画面の初期表示のタイミングでは比較的使いやすいかつユーザーフレンドリーな方法かなと思います。 単にインジケータを出す場合に比べ、実装は少々手間ですがこのローディングの表示は個人的に好きです。

導入

CocoaPods, Carthage, SwiftPackageManager に対応していますのでどれでも好きなツールを選んでプロジェクトに組み込むことができます。

CocoaPods

# Podfile

pod "SkeletonView"

$ pod install

Carthage

# Cartfile

github "Juanpe/SkeletonView"

$ carthage update

SwiftPackageManager

// Package.swift


dependencies: [
    .package(url: "https://github.com/Juanpe/SkeletonView.git", from: "1.7.0")
]

使い方

isSkeletonable を true に

まずは Shimmer を適用したい View の isSkeletonable プロパティを true にします。(Storyboard, Xib 上では On になります。)
isSkeletonable は UIView の extension として定義されているので導入すればどの View にも生えてきます。
Shimmer を適用したくない場合は isSkeletonablefalse にすればその View には Shimmer が適用されません。
また、isSkeletonable は Storyboard や Xib からでも設定することができるのであまり View に関するコードを増やしたくない場合でも安心ですね。
Sansan では基本的に Storyboard, Xib から設定をしています。

f:id:chaaaaanu:20191023100951p:plain
isSkeletonable の On/Off を設定

// コードで設定する場合
private func setupSkeleton() {
    view.isSkeletonable = true

    headerContainerView.isSkeletonable = true
    userIconImageView.isSkeletonable = true
    userNameLabel.isSkeletonable = true
    captionLabel.isSkeletonable = true
    tableView.isSkeletonable = true
}

この時に気をつけなくてはいけないのが View 階層全ての isSkeletonable を設定すること。

次の章でもお話しますが SkeletonView は再帰的に Shimmer を適用しようとするため、階層のルートから末端までの間に一つでも isSkeletonable が設定されていない View があると、その View の subview 以降には Shimmer が適用されなくなってしまい、意図しない Shimmer が表示されてしまうのでご注意ください。(全画面 Shimmer になってしまうとか。)

f:id:chaaaaanu:20191024145100p:plain
こんな風に表示したいけど・・・

f:id:chaaaaanu:20191024145116p:plain
Header が塗り潰されてしまった・・・

Shimmer の表示 / 非表示

isSkeletonable を設定し終えたら Shimmer を適用したい範囲の大元の View の showAnimatedSkeleton メソッドを呼び出します。

これで呼び出した View から subview を辿って行き、 subview がなくなるか isSkeletonablefalse か未設定になっている View に当たるまで Shimmer を適用していってくれます。 アニメーションさせないで灰色の View だけ出したい! という場合は showSkeleton というメソッドを呼び出せばアニメーションしない Shimmer が表示されます。 逆にAPIなどからデータが返ってきて Shimmer を非表示にする時は showAnimatedSkeleton or showSkeleton を呼び出した View の hideSkeleton メソッドを呼び出してあげれば Shimmer が消えます。
以下はボタンのタップで Shimmer の表示と非表示を切り替える例です。

@IBAction func skeletonToggleAction(_ sender: UIButton) {
    if nowShowSkeleton {
        nowShowSkeleton.toggle()

        view.hideSkeleton() // ここで Shimmer 非表示
    } else {
        nowShowSkeleton.toggle()

        view.showAnimatedSkeleton() // ここから Shimmer 表示
    }
}

使い方まとめ

使い方は以上の通りです!

  1. isSkeletonable を設定して
  2. view.showAnimatedSkeleton メソッドを呼び出して
  3. view.hideSkeleton メソッドを呼び出す

この 3ステップで Shimmer を表示したり、引っ込めたりすることができるので非常にお手軽ですね! ただ、少しライブラリに癖があり実際に使ってみるといくつか悩んだところがあったのでそちらもご紹介したいと思います。

ハマったところ

設定したはずのテキストが消える!?

基本的に Shimmer を表示するタイミングというのは時間のかかる非同期処理の前後であると思います。 Sansan ではデータの読み込みから Shimmer の非表示まで以下の順番で行っていました。 実際のコードは少々複雑なためわかりやすいようにサンプルコードで例を示します。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1. 表示に必要なデータの fetch 
        fetchData(completion: { [weak self] data in
            DispatchQueue.main.async {
                // 3. 取得したデータのセット
                self?.view.textLabel.title = data.title

                // 4. Shimmer を非表示に
                self?.view.hideSkeleton()
            }
        })

        // 2. Shimmer を表示する
        view.showAnimatedSkeleton()
    }
}

流れとしては特殊な所はありませんがこの実装だとtextLabel.title に設定したテキストは消えるか、以前の状態のテキストのまま更新されないという結果になります。 問題だったのはデータ取得後の処理の順番で Shimmer を消した後 textLabel にデータをセットするべき でした。

この順番だとテキストが消える(もしくは更新されない)😢

DispatchQueue.main.async {
    // 3. 取得したデータのセット
    self?.view.textLabel.title = data.title

    // 4. Shimmer を非表示に
    self?.view.hideSkeleton()
}

この順番だと大丈夫😄

DispatchQueue.main.async {
    // 3. Shimmer を非表示に
    self?.view.hideSkeleton()

    // 4. 取得したデータのセット
    self?.view.textLabel.title = data.title
}

なぜこのような結果になるかコードを眺めていると SkeletonView は showAnimatedSkeleton が呼び出されて、各 View に対して Shimmer を設定する際に その時点での各 View の主要なプロパティの内容をストアする ようです。
どのプロパティの内容がストアされるかは Shimmer を適用する View によって少しずつ異なり、 UILabel や UITextField では text プロパティの内容をストアします。
ストアされたデータ達は hideSkeleton を呼び出して Shimmer を解除する際に View に適用されます。
なので、hideSkeleton を呼び出す前に title プロパティにデータを設定すると、設定したはずのテキストがストアされているデータで置き換えられてしまいテキストが消えたような挙動になってしまっていたというお話でした。

ボタンの反応がとても悪くなる

UIButton に対して以下のように Shimmer を表示させたかったので、 button.titleLabelbutton.imageView に対して showAnimatedSkeleton をそれぞれ直接呼び出していました。
(isSkeletonable に true を設定するだけでいいということに気づいたのはこの記事を書いている時でした...🙄)

f:id:chaaaaanu:20191023012046p:plain
こんな感じです

func showAnimatedSkeleton() {
    view.showAnimatedSkeleton()

    [telephoneButton, mailButton, mapButton, contactButton].forEach {
        $0.showAnimatedSkeletonView()
    }
}

func hideSkeleton() {
    view.hideSkeleton()

    [telephoneButton, mailButton, mapButton, contactButton].forEach {
        $0.hideSkeletonView()
    }
}

Shimmer 自体はうまく表示されその他問題も無いように思えたため、満足していたのですが同じチームの方から Shimmer を非表示にした後のボタンの反応がとても悪い (押せることもある) と相談を受けました。
タップ可能な領域が小さくなっていないかを調べたり、ボタンの前面に余計な View がいないか調べてみましたが特に問題はなく行き詰まってしまったのでまた SkeletonView のコードを眺めていると・・・

f:id:chaaaaanu:20191023011837p:plain
んん?
f:id:chaaaaanu:20191023012409p:plain
君か・・・😇

スクショの通り SkeletonView は hideSkeleton が呼び出されて Shimmer を解除する際、Shimmer の対象になった全ての View の isUserInteractionEnabledtrue に設定 します。 今回の場合 UIButton 内部の imageView と titleLabel にも Shimmer を適用していたので それぞれ isUserInteractionEnabledtrue に設定されてしまい、ボタンまでタッチイベントが届いておらず、ボタンの反応が悪くなってしまっていたというお話でした。 少々面倒ですが hideSkeleton した後に imageView と titleLabel の isUserInteractionEnabledfalse にしてあげることで解決しました。

isSkeletonable が消えた...!?

これはなぜ発生したのか良くわかっていない問題なのですが、SkeletonView のライブラリを 1.6.2 -> 1.8.2 にアップデートした際に Storyboard, Xib から isSkeletonable が設定できなくなるという問題 がありました。問題が発生した時は Carthage を使ってインストールしていましたが CocoaPods 経由でインストールするようにしたところ、解決しました。 状況をみる限りでは Carthage の問題のような気がしますが分かる方がいらっしゃいましたらどなたか教えていただきたいです🙇‍♂️

まとめ

SkeletonView は綺麗な Shimmer の表示をお手軽に実現できる素晴らしいライブラリだと思います。 また、今回ご紹介できませんでしたが色の変更や MultilineText の対応、UICollectionView, UITableView への適用など機能もカスタマイズ要素も豊富な上、README に大抵の使い方は書いてあるため導入は非常にしやすいライブラリだと思いました。 ただ、今回ご紹介したように少々癖があり結構しっかりハマってしまうことがあったのでそこだけ少し注意が必要かなと思いました。

今回使用したコードはこちらにプロジェクトがありますので合わせてご覧ください。 github.com

© Sansan, Inc.