Sansan Tech Blog

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

TipKitで「初回だけ」新しいタブの存在に気づいてもらう

こんにちは。技術本部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ユーザーを指します。 既存の連絡先体験の延長で、「同じ会社の人が見つかる」という新しい発見を提供するタブになります。

連絡先画面の同僚タブに表示されるTip
連絡先画面のタブ列に追加した「同僚」タブに対して、TipKitのポップオーバーTipを表示しています。 Tipはタブ(「同僚」)を指す形で出し、「Eightを利用している同僚がわかるようになりました」というメッセージで新しい導線の存在に気づいてもらう意図です。

課題:既存ユーザーほど新規タブに気づきにくい

タブが追加されて「目に見えている」状態でも、ユーザーの利用文脈によっては見落とされることがあります。既存ユーザーは連絡先画面の操作に慣れているため、視線や操作が固定化しやすく、新しいUI要素(今回だと同僚タブ)が目に入りにくい状態になりがちです。 そこで、連絡先画面を開いたタイミングで同僚タブに短いメッセージのツールチップ(Tip)を表示し、「新しい導線が増えた」ことを追加の実装・運用コストを抑えつつ伝える方針にしました。

表示要件

  • 連絡先画面を開いたとき、同僚タブにツールチップが表示される
  • ツールチップは同僚タブリリース後に当アプリで初めて連絡先を開いたときのみ表示される(端末単位)
※ 本記事では「初回」を端末(ローカル)で1回の意味で扱います(TipKitの表示回数は端末ローカルで管理されるため、再インストールなどで再表示される可能性があります)。

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バージョン差あり)

実プロダクトではContactsTabHeaderViewUIKit側から 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・デザイナーと一緒に「どこまでを仕様上の制約として受け入れるか」を判断しました。
OSカテゴリ別アクティブユーザー数の割合
当時のDAUはiOS 26系が 20.9%、iOS 18系が 75.9%、iOS 17系が 3.1% という分布でした。 この状況では、iOS 17系での見た目の差分(例:矢印部分の色が揃わない/カスタマイズが効かない)を“完全一致”させること自体を目的化せず、プロダクトバックログ上で本質的に実現したいこと(= 同僚タブの存在に初回だけ気づいてもらう)を優先しました。 その結果、iOS 17系については 仕様上の制約として受け入れる(ただし「テキストが判読できる」「タブを指していると分かる」「操作の邪魔にならない」は担保する)という整理で合意しています。
実際のワークアラウンド: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に埋め込む方法(UIHostingConfiguration vs UIHostingController)や、iOSバージョンによって挙動差が出る
  • 今回は、UIHostingControllerを子ViewControllerとして組み込む回避策やDAUを踏まえた割り切りにより、プロダクトとしての本質価値(初回の気づき)を優先して解決

おわりに

Sansanでは、共にSansan / Eightのモバイルアプリを開発していく仲間を募集中です! 選考評価なしで現場のエンジニアのリアルな声が聞けるカジュアル面談もありますので、ご興味ありましたらぜひ面談だけでもお越しいただければ幸いです!

© Sansan, Inc.