iOSにおけるツールチップの実装

Jinsei Shima
Eureka Engineering

--

この記事は「Eureka Advent Calendar 2020」の2日目の記事です。

1日目はMuukiiによる「iOSアプリにおけるFluxの難しさと開発を加速させる”store-pattern”」でした。

エウレカのiOSエンジニアの Shima です。 Pairsの日本版と台湾・韓国版のiOSアプリの開発を担当しております。

NavigationBarからツールチップを表示している例

みなさん、ツールチップを実装したことはありますか? (画像の緑の吹き出しのようなコンポーネント)

Pairsでは、新機能をリリースするタイミングなど、徐々にツールチップを使うケースが増えてきました。
都度実装するのも大変ですし実装が難しいケースもあるので、今回汎用的に使えるツールチップの実装について考えてみました。

※ 本記事では、ツールチップの表示とタップをハンドリングするための仕組みについての説明がメインで、UIの実装についてはあまり触れないです。

ツールチップとは

一般的にツールチップはマウスオーバーなどしてコンポーネントの補足情報を表示する場合に用いられるコンポーネントです。

UIKitでは公式にツールチップというコンポーネントは定義されていませんが、この記事ではボタンなど特定のコンポーネントを指して補足説明するためのコンポーネントをツールチップと呼んでいます。

モバイルアプリケーションにおいてコンバージョンを高めたり新機能への認知を向上させるための手段として有効なアプローチのひとつかなと思います。

似ているコンポーネントとしてiOSではPopovers、Context Menus、Edit Menusなどがあります。

モバイルアプリケーションにおいてツールチップに求められる要件

主にオンボーディングやチュートリアルにおいて、ユーザに新しい機能を気付きやすくしたり補足情報を伝えるために用いられることが多いです。表示される場所や操作したときの挙動も様々です。

  • 画面をブロックしてボタンを押さないと次へ進めないタイプ
  • 画面をタップするとツールチップが消えるタイプ
  • ツールチップを表示したままでも他の操作ができるタイプ
各アプリでのツールチップの使用例

どのように実装できるか考えてみる

ボタンに対してツールチップを表示したいケースを考えていきます。
要件としてツールチップを表示したまま他の操作が可能であることとしています。

ボツ案1:そのままaddSubview

まず普通にButtonの上辺りにTooltipをviewにaddSubviewしてみます。
これだけであればシンプルに実装できそうです。

ツールチップをそのままaddSubview

ですが、この方法には弱点があります。

それはTooltipがsuperViewからはみ出した場合にタップができなくなることです。図で表すと次のようなケースです。

ツールチップをタップができないケース

例えば、TooltipをNavigationBar(図中: nav)にaddSubviewしただけだと、矢印の箇所をタップしたとしてもTooltipへのタップではなくViewへのタップとして判定されてしまいます。

ボツ案2:ツールチップ用のWindowを用意する

次に考えたアプローチとして、UIWindowをフルスクリーンで前面に表示し、そこにツールチップを表示する方法です。

こうすることで、他のWindowと重ならない限りは、Tooltipのタップを確実にハンドリングすることができます。

ツールチップ用のWindowにツールチップを表示

ですが、この方法にも弱点があります。

それは、Tooltipの表示中にボタンが移動もしくは非表示になるケースです。図で表すと次のようなケースです。

ボタンだけ移動するケース

例えば、ScrollViewの中にTooltipを表示したい時、スクロールが発生するとWindowに対してもButtonの位置を同期してTooltipの位置を更新する必要があります。
他のケースとして画面遷移を行う場合も同様の問題が発生します。

仮にTooltip表示中は他の操作ができないようにしたとしても、キーボードの切り替えや通知による更新でScrollViewが動かないようにする実装が必要になります。

ボツ案3:UIPopoverPresentationControllerを使う

ボツ案2と近い方法として、UIPopoverPresentationControllerを使う方法もあります。

UIPopoverPresentationControllerをpresentすると、ViewControllerを全面に表示して、TooltipのコンテンツをContainer ViewControllerとしてSourceViewに対して適切な位置に表示させるものになっています。

ツールチップの吹き出しのUIがデフォルトで実装されていたり、画面からはみ出さないように表示してくれたり、吹き出しの方向などもいい感じに対応してくれるので楽です。

ただ、Human Interface Guideline にはPopoverは何かをタップしたときにコンテンツの上に表示される一時的なViewだとされていて、画面領域の小さいiPhoneではあまり使うべきではないと記述されています。
なので使い所は検討する必要がありますが、場合によっては有効なアプローチのひとつかなと思います。

あとこの方法だと、ツールチップを表示したまま他の操作をしたいケースには使えないです。

UIPopoverPresentationControllerを使った場合のView Hierarchy

すべての要件を満たす方法を考える

ここまでの実装と課題を踏まえた上で、この2点をクリアできる必要があります。

  • 対象のViewとツールチップの位置が同期可能であること
  • 表示する場所を制限することなくツールチップのタップがハンドリング可能であること

これを解決するためのアプローチ

  • 対象のViewとツールチップを管理するContainerViewを作成
  • UIWindowのhitTestをオーバーライドしてツールチップがタップされているかを判定

対象のViewとツールチップを管理するContainerViewを作成

Target Viewに対してツールチップを表示するためのコンテナとしてTooltipContainerViewを作成します。
こうすることで、Target Viewとツールチップが一つのViewにまとまるので、Target Viewが移動するケースや画面から消えるケースにおいてツールチップだけ取り残されるみたいなことが起きにくくなります。

TooltipContainerViewの構成

UIWindowのhitTestをオーバーライドしてツールチップがタップされているかを判定

ContainerViewを追加しただけだとツールチップがsuperViewからはみ出たときにタップの検出が不可能です。(NavigationBarに追加したいケースなど)

TooltipContainerViewのhitTestをオーバーライドすればいいかと思ったのですが、superViewのsuperViewよりもはみ出てしまった場合は、そもそも呼ばれないのでViewだけでどうにかする方法はだめでした。

そこで、UIWindowのhitTestをオーバーライドしてツールチップがタップされているかを判定をするアプローチを試みました。

(UIWindowのsendEventをオーバーライドして直接タッチイベントを呼ぶアプローチも検討しましたが、これだとツールチップ以外のViewに対してのタッチイベントも発火してしまうので、UIWindowのhitTestをオーバーライドしてタップ判定そのものをツールチップへのタップとするようにしました。)

Hit-testingとは

Hit-Testing Returns the View Where a Touch Occurred iOS uses hit-testing to find the view that is under a touch. Hit-testing involves checking whether a touch is within the bounds of any relevant view objects. If it is, it recursively checks all of that view’s subviews. The lowest view in the view hierarchy that contains the touch point becomes the hit-test view. After iOS determines the hit-test view, it passes the touch event to that view for handling.

引用元: Event Handling Guide for iOS

Hit-testingとは、画面がタップされたときにどのViewがタップされたかを判定するためのものです。

実際には、画面のタップが発生するとタップされた座標をもとにUIWindowからhitTestが呼ばれます。
hitTestの内部ではsubViewに対して point(inside:with:) が呼ばれていて、タップ可能なViewが存在するかを判定しています。そして、最前面のViewが見つかるまで探索を行っています。

タップされたViewが見つかると、対象のViewに対してタップイベントが送信されて、Viewのタップイベントが発火します。

ツールチップのタップのハンドリング

  1. UIWindowのサブクラスを作成して、表示されているツールチップを保持できるようにします。(NSHashTable)
  2. ツールチップがWindowに追加されるタイミング(didMoveToWindow)でResponderChainをさかのぼってWindowに対してツールチップを追加します。
  3. UIWindowのhitTestをオーバーライドして、ツールチップが表示されているときだけタップ領域の判定をします。タッチされている座標とWindowに対するツールチップの座標を比較してツールチップがタップされているかを判定します。
ツールチップのタップをハンドリングするためのWindowの実装
Windowに対してツールチップを追加する処理

階層が異なる2つのViewの重なり順を判定

ここまでの実装だと、UIWindowのhitTestをオーバーライドしてツールチップのある領域がタップされたらツールチップのタップが発火してしまう仕組みなので、ツールチップの上に何かが表示されているケースでもその領域へのタップだと問答無用でツールチップのタップになってしまう問題があります。

それを回避するためには、タップされたViewとツールチップのどちらが上にあるかを判定する必要があります。

そこで、UIViewのsubViewsが表示順に並んでいることに着目して、2つのViewに対してどちらが前面に表示されているかを判定するようにしました。

“Visually, the content of a subview obscures all or part of the content of its parent view. Each superview stores its subviews in an ordered array and the order in that array also affects the visibility of each subview. If two sibling subviews overlap each other, the one that was added last (or was moved to the end of the subview array) appears on top of the other.”

引用元: View and Window Architecture

UIViewのsubViewにおける重なり順はsubViewsの配列の順番で表現されています。
ただ、タップされたViewとTooltipのViewが同じViewのsubViewとは限らないので、それぞれのViewのResponderChainをさかのぼって見ていく必要があります。

まず、それぞれのViewのReseponderChainをさかのぼって最初に見つかる共通のView(common parent)を見つけます。
そして、その共通のViewのsubViewsに対してどちらが配列の後ろ側にいるかを判定します。
そうすることで、同じ階層にいない特定の2つのViewの重なり順がわかります。
TooltipとViewが重なる場合で大きく2つのパターンがあり、それぞれ図に表すと次のようになります。

パターン1:ツールチップの下にボタンがあるケース

パターン1のViewの重なり

表示上はツールチップのほうが前面に見えていますが、UIWindowのhitTestではbuttonが返ってきます。

パターン1のView Hierarchy(概略)

パターン2:ツールチップの上にフローティングのボタンがあるケース

パターン2のViewの重なり

ScrollViewに対してツールチップが表示されていて、Viewに対してフローティングのボタンが表示されている場合だと、UIWindowのhitTestではbuttonが返ってきます。

パターン2のView Hierarchy(概略)

上記のアプローチを実装におとすと次のようになります。

階層の違う2つのViewの重なり順を判定する実装

ここまで実装すると、この2つの課題をクリアすることができます。

  • 対象のViewとツールチップの位置が同期可能であること
  • 表示する場所を制限することなくツールチップのタップがハンドリング可能であること

このアプローチにすることで、ツールチップを表示する場所を気にしなくてもいいのと、ツールチップが取り残されることが起きにくくなります。

現状の懸念点としては、Containerを使っている分レイヤーが一つ増える点と、アプリの階層構造が深くなりすぎると全体のタップに対するパフォーマンスに影響を与える可能性がある点です。(ほとんどの場合で無視できる範囲だとは思います)

まとめ

iOSにおける汎用的なツールチップの実装について、試行錯誤しながら考えてみました。
そして、同一のWindow内であればどこにでも表示できて、対象のコンポーネントと位置の同期が可能なツールチップを実装してみました。

ツールチップを表示したいケースはそこそこあると思いますが、しっかりと実装しようとすると意外と難しいポイントが多いツールチップです。

要件によってはもっと簡易的なアプローチで大丈夫なこともあると思いますが、ツールチップの表示中も他の操作をしたいとか、コンテンツとツールチップの位置を同期させたい場合には有効なアプローチのひとつかなと思います。

--

--