こんにちは。技術本部Eight Engineering Unit Mobile ApplicationグループでiOSエンジニアをしている小清水(@_take_hito_)です。
本記事では、Eight iOSアプリの連絡先画面に新設した「同僚」タブに対して、Apple標準のTipKitを使って「初回だけ」ツールチップを表示した背景と、実装中にハマったポイントを共有します。
この記事で扱うこと(TL;DR)
- TipKitの
.popoverTip(...)を使うと、特定のUI要素にアンカーしたTipを表示できる - 「初回だけ表示する」要件は
MaxDisplayCount(1)で満たせる - ただし、UIKit混在とOSバージョン差分には注意が必要
対象読者/前提
- UIKitベースの画面にSwiftUIを部分導入している(または検討している)
- TipKit(iOS 17+)の導入を検討している
検証環境(本記事で言及する範囲)
- 画面構成:UIViewControllerで構築した画面にSwiftUIを埋め込む
- 確認OS:iOS 17系 / iOS 18+(iOS 26+ の挙動はiOS 18と同様)
背景:新しいタブの「存在」に気づいてもらいたい
Eight iOSアプリでは、連絡先画面に新しいタブ(「同僚」)を追加しました。目的は、プロダクト内で Eight Team(中小企業向け名刺管理サービス)の価値をアプリ内の導線として伝えられるようにすることです。
ここでいう「同僚」とは、ユーザーの主務の名刺と同じ会社に所属している自身以外のEightユーザーを指します。 既存の連絡先体験の延長で、「同じ会社の人が見つかる」という新しい発見を提供するタブになります。

課題:既存ユーザーほど新規タブに気づきにくい
タブが追加されて「目に見えている」状態でも、ユーザーの利用文脈によっては見落とされることがあります。既存ユーザーは連絡先画面の操作に慣れているため、視線や操作が固定化しやすく、新しいUI要素(今回だと同僚タブ)が目に入りにくい状態になりがちです。 そこで、連絡先画面を開いたタイミングで同僚タブに短いメッセージのツールチップ(Tip)を表示し、「新しい導線が増えた」ことを追加の実装・運用コストを抑えつつ伝える方針にしました。表示要件
- 連絡先画面を開いたとき、同僚タブにツールチップが表示される
- ツールチップは同僚タブリリース後に当アプリで初めて連絡先を開いたときのみ表示される(端末単位)
iOS実装要件に落とすとこうなる
プロダクト要件をiOS実装の観点に翻訳すると、重要なのは次の3点でした。特定のUI要素(タブ)にアンカーして表示できること
ただのトーストやバナーではなく、タブそのものを指して「ここが増えました」と伝えたい。表示は初回のみで、以降は出さないこと
学習した後はノイズになるため、「リリース後に初めて連絡先画面を開いた1回」に限定する。既存画面のレイアウトや操作導線を崩さないこと
連絡先画面は利用頻度が高いので、導入によるUI崩れや運用コストは増やしたくない。なぜTipKitを採用したのか
ツールチップ自体は独自実装でも作れます。しかし今回は、できるだけ独自コンポーネントを増やさず、システム標準に寄せたいという判断が大きく、TipKitを採用しました。Apple標準のUI/挙動に寄せやすい
自由度は下がる一方で、OS標準の体験から大きく外れにくく、案内の情報量を増やしすぎずに済む。チーム内の学習コストを避けられる
独自UIは「APIの作法」「表示条件の設計」「運用ルール」までセットで増えやすい。独自実装によるバグ混入リスクを下げたい
独自実装では、表示位置の調整、Dynamic Typeへの追従、アクセシビリティ対応など、細かなケース対応が積み上がりやすいです。 こうした論点はツールチップに限らずUI実装一般で起こり得るため、今回は「標準で賄える範囲は標準に寄せる」方針を優先しました。さらに大きかった判断材料:iOS 17.0+という前提が揃っていた
また、Eightアプリのシステム要件が iOS 17.0+ だったことも採用判断を後押ししました。 直近でサポートOSバージョンを更新したことで、TipKitを前提にした設計が取りやすくなり、前述のメリットを素直に生かせると判断しました。TipKitの最小実装イメージ
TipKitでは、Tip(表示する内容)をTip として定義し、表示したいUI要素に .popoverTip(...) を付けます。
※ TipKitは利用前に Tips.configure() を一度呼ぶ必要があります(UIKitアプリなら AppDelegate.application(_:didFinishLaunchingWithOptions:) でアプリ起動時に1回)。本記事では初期化済みである前提で、Tip定義と表示側にフォーカスします。
1) Tipを定義する(タイトルのみ + 初回のみ表示)
import TipKit import SwiftUI struct ContactColleaguesAppealTip: Tip { var title: Text { Text("Eightを利用している同僚がわかるようになりました") } var options: [any TipOption] { IgnoresDisplayFrequency(true) MaxDisplayCount(1) } }
MaxDisplayCount(1)により、このTipは最大1回だけ表示され、以降は表示されません。IgnoresDisplayFrequency(true)により、アプリ全体の表示頻度設定の影響でTipが出ないケースを避けます。
2) 表示したいUI要素に .popoverTip を付ける
import SwiftUI import TipKit struct ContactsTabHeaderView: View { private let tip = ContactColleaguesAppealTip() var body: some View { HStack(spacing: 12) { Button("あなたの知り合い") { // タブ切り替え } Button("同僚") { // タブ切り替え } .popoverTip(tip) // ← ここに付けるだけ } } }
ハマりポイント
ハマりポイント1:UIHostingConfiguration経由だと.popoverTipが表示されない(iOSバージョン差あり)
実プロダクトではContactsTabHeaderViewをUIKit側から UIHostingConfiguration で利用しており、この構成でハマりました。
何が起きたか
UIHostingConfiguration経由で配置したContactsTabHeaderViewでは、こちらの検証環境だと.popoverTip(...)がiOS 18.5 以下で表示されなかった- 一方で、同じコードでもiOS 18.6では表示できた(iOSバージョンによって挙動差があった)
なぜ起きるのか(ここでの理解)
原因を断定できているわけではありませんが、TipKitというよりSwiftUIをUIKit上に配置する際の内部実装差に起因している可能性があると考えています。UIHostingConfigurationはSwiftUI ViewをUIKitの内部構造に“構成要素として”差し込むため、iOSバージョンによっては.popoverTipの表示に必要な前提(アンカー解決や表示レイヤー)が満たせないケースがありました。
この挙動はSwiftUI内部実装に依存するため、アプリ側だけで根本解決するのは難しいタイプの問題です。
ワークアラウンド:UIHostingControllerを子ViewControllerとして組み込む
回避策として、SwiftUI ViewをUIHostingControllerでラップし、UIKit側でChild View Controllerとして接続する方式に切り替えました。
この方法では意図どおり.popoverTipが表示されました。
実装例(UIHostingControllerをchildとして接続し、UIStackViewに載せる)
final class ContactsHeaderContainerViewController: UIViewController { @IBOutlet private weak var stackView: UIStackView! private lazy var hostingController: UIHostingController<ContactsTabHeaderView> = { let controller = UIHostingController(rootView: ContactsTabHeaderView()) controller.view.translatesAutoresizingMaskIntoConstraints = false return controller }() override func viewDidLoad() { super.viewDidLoad() // UIKitのViewController Containmentの手順に沿って接続する addChild(hostingController) stackView.addArrangedSubview(hostingController.view) // = 親のview階層に追加 hostingController.didMove(toParent: self) } }
ポイントはaddChild→(親のview階層へ追加)→didMove(toParent:)の順で View Controller階層に正しく参加させることです。
ハマりポイント2:Tipの見た目カスタマイズがiOSバージョンで揃わない(iOS 17系で制約が強い)
同じ.popoverTip(...)でも、**iOSバージョンによってTipの見た目・カスタマイズ可否が変わる**点でハマりました。
特にiOS 17系では「カスタマイズが一部だけ効く / まったく効かない」ケースがあり、見た目の一貫性を前提にすると設計が崩れます。
起きたこと(iOSバージョン差)
- iOS 18:指定したTipViewStyleで表示できる
- iOS 17.5:矢印(arrow)部分の色が意図どおりに揃わない
- iOS 17.2 以下:TipViewStyleが適用されない
スクリーンショット
| iOS 18 | iOS 17.5 | iOS 17.0 |
|---|---|---|
| TipViewStyleが適用される | 矢印色が揃わない | TipViewStyleが適用されない |
|
|
|
対応方針:iOS 17系は仕様上の制約として受け入れる
「見た目の完全一致は狙わず“壊れない”方向に寄せる」という方針にしました。 iOSバージョンごとの利用率(DAU)を確認したうえで、PdM・デザイナーと一緒に「どこまでを仕様上の制約として受け入れるか」を判断しました。
実際のワークアラウンド:iOS 17.2以下は“デフォルトに寄せる”分岐で吸収する
前述の通り、iOS 17系ではTipのカスタマイズ(TipViewStyleの適用)が一部期待どおりに効かないケースがありました。 今回は iOS 17.3以上ではデザインを適用し、iOS 17.2以下ではデフォルト(システム寄り)に寄せる分岐で吸収しています。Tipのタイトル側:iOS 17.2以下はテキスト修飾をしない
import TipKit import SwiftUI struct ContactColleaguesAppealTip: Tip { var title: Text { let text = Text("Eightを利用している同僚がわかるようになりました") if #available(iOS 17.3, *) { return text .font(.callout.weight(.semibold)) .foregroundStyle(.white) } else { // NOTE: // iOS 17.2 以下はスタイルの適用が期待通りに動作しないのでテキストの修飾はしない return text } } var options: [any TipOption] { IgnoresDisplayFrequency(true) MaxDisplayCount(1) } }
TipViewStyle側:iOS 17.2以下はデフォルト(MiniTip)にフォールバックする
TipViewStyleは、Tipを表示するView階層に.tipViewStyle(...) を指定して適用します。
ContactsTabHeaderView()
.tipViewStyle(PopoverTipViewStyle())
import TipKit import SwiftUI public struct PopoverTipViewStyle: TipViewStyle { public init() {} public func makeBody(configuration: TipViewStyle.Configuration) -> some View { if #available(iOS 17.3, *) { configuration.title .padding(.vertical, 20) .padding(.horizontal) .background(Color.black.opacity(0.85)) } else { // NOTE: // iOS 17.2 以下はスタイルの適用が期待通りに動作しないのでデフォルトのスタイルを適用する。 MiniTipViewStyle.miniTip .makeBody(configuration: configuration) } } }
まとめ
- TipKitは
.popoverTip(...)を付けるだけで、UI要素にアンカーしたTipを手軽に表示できる - 一方で、SwiftUIをUIKitに埋め込む方法(
UIHostingConfigurationvsUIHostingController)や、iOSバージョンによって挙動差が出る - 今回は、
UIHostingControllerを子ViewControllerとして組み込む回避策やDAUを踏まえた割り切りにより、プロダクトとしての本質価値(初回の気づき)を優先して解決


