cockscomblog?

cockscomb on hatena blog

あなたの知らない UIKit の世界 — UITableView に UITextView を置きたい

f:id:cockscomb:20160906232707p:plain:h540:right

UITableView の見た目で様々な要素を表示しつつ、その一部として UITextView を使いたい、という需要があると思う。最も身近な例は標準のメールアプリである。

これは UIKit の API を駆使することで実現可能である。本記事ではこれを実現する方法を通して、あまり知られていないであろう UIKit の機能について紹介する。

UITextView の intrinsic content size

Auto Layout において、UIView は自身が表示されるためにちょうどよいサイズを知っている。具体的には var intrinsicContentSize: CGSize である(Swift 3.0 では property で、Swift 2.3 まではメソッドである)。このサイズは他の NSLayoutConstraint と関連して、デフォルトではプライオリティ 750 で縮みにくく、プライオリティ 250 で広がりにくい。

例えば UIImageView では画像のサイズがこれに相当するし、UILabel では文字列を描画するのに最低限必要なサイズと等しくなるはずである。

ここで UITextView の intrinsic content size はどうかというと、そのままでは widthheight-1CGSize が返る。UITextViewUIScrollView を継承しているので、表示する内容に関わらず、ちょうどよいサイズというものを持たないのである。しかしここで、UIScrollView 由来の var isScrollEnabled: Bool property に false を設定すると、こんどはちょうどよいサイズが返るようになる。

let textView = UITextView()
textView.text = "Think different"

textView.intrinsicContentSize() // {w -1 h -1}

textView.scrollEnabled = false

textView.intrinsicContentSize() // {w 86 h 30}

UITextView の intrinsic content size (Swift 2.3)

この挙動を利用して UITableViewCell の Auto Layout を設定することができる。

ところで UITextViewUIScrollView を継承しているのは正当で、内容となる文字列が非常に長い場合に、表示されていない部分のレイアウトを遅延させることができる。実際に、長大な内容の UITextViewvar contentSize: CGSize を取得すると、場合によっては本来のサイズが返ってこないはずだ。逆に言えばスクロールを無効にした UITextView では、長大な内容を持つ場合に明確なパフォーマンスの低下がみられることに留意するべきである。具体的な仕組みに関しては NSLayoutManager Class Reference の “Noncontiguous Layout” を参照すること。

UITableView の Auto Layout

UITableView の self-sizing cell について、詳細は “Working with Self-Sizing Table View Cells” を参照。

UITextView を置いた UITableViewCell に適切に NSLayoutConstraint を設定する。このとき最小の高さを設定しておく方が良いかもしれない。あとは UITableViewAutomaticDimension を高さとして返す。

UITableViewCell の高さの更新

一度表示された UITextView の内容が変更される場合、単に Auto Layout を利用していれば func invalidateIntrinsicContentSize() の効果で全体が更新されるはずである。一方で UITableViewCell の場合は、高さの再計算が必要であることを UITableView に伝える必要がある。ただし cell をリロードするといったことは避けたい。

UITableView では、func beginUpdates()func endUpdates() を連続して呼び出すことで、 cell の高さを再計算させることができる。これはドキュメンテーションされた挙動である。UITableView Class Reference の beginUpdates() から、discussions 部分を引用する。(強調は筆者によるもの)

Call this method if you want subsequent insertions, deletion, and selection operations (for example, cellForRowAtIndexPath: and indexPathsForVisibleRows) to be animated simultaneously. You can also use this method followed by the endUpdates method to animate the change in the row heights without reloading the cell. This group of methods must conclude with an invocation of endUpdates. These method pairs can be nested. If you do not make the insertion, deletion, and selection calls inside this block, table attributes such as row count might become invalid. You should not call reloadData within the group; if you call this method within the group, you must perform any animations yourself.

このように cell のリロードなしに高さのアニメーションを行えることが明記されている。

これを利用して、var text: String? property を変更したあとや、あるいは var isEditable: Bool が true であればユーザーの入力に合わせて、beginUpdates()endUpdates() を呼び出すことで、Auto Layout と合わせて適切に cell の高さが変化する。

extension ViewController: UITextViewDelegate {

    func textViewDidChange(textView: UITextView) {
        UIView.performWithoutAnimation {
            self.tableView.beginUpdates()
            self.tableView.endUpdates()
        }
    }

}

ユーザーの入力に合わせて高さを更新する。この場合は煩わしいのでアニメーションを無効にしている。(Swift 2.3)

まとめ

UITextView のスクロールを無効にすることで intrinsic content size が変化し Auto Layout でうまく扱えることを示した。これを UITableViewCell に置いて、UITableView の self-sizing cell として利用できることを明らかにした。そして UITableViewbeginUpdates()endUpdates() のドキュメントされた挙動によって、cell の高さを再計算させられることを確認した。

これらの UIKit の挙動はあまり知られていないように思う。ひとつひとつは些細だが、組み合わせることで強力なものとなる。UIKit についての知見を深めることは、すばらしいアプリを開発するための近道である。


以上の内容は8/31の関西モバイルアプリ研究会で発表したものです。

iPhone OS 2.0 とともに UIKit が公開されて、ついには iOS 10 の正式リリースがすぐそこまで迫っている昨今ですが、長年の経験を持つ私たちでさえまだまだ知らないことが山ほどあるように思います。UIKit は非常に巧妙に設計された GUI フレームワークですから、ユーザー体験を改善するために必要な様々な仕掛けが用意されていたりします。

特に近年では、Swift というリッチな言語や、モダンパラダイムやライブラリ、あるいは種々の開発手法などと、モバイルアプリに関する話題には事欠きません。しかしながら初心に立ち返って、慣れ親しんだはずのフレームワークを子細に観察してみると、意外な発見があるかもしれません。今後もライフワークとして学び続けて、API を通して Apple に近づいていきたいと思います。