$30 off During Our Annual Pro Sale. View Details »

Footprints about Contribution of DroidKaigi 2023

Fumiya Sakai
September 13, 2023

Footprints about Contribution of DroidKaigi 2023

9/26に開催された「potatotips #84」&9/13に開催された「After iOSDC LT Night〜ピクシブ×日経×タイミー〜」での登壇資料になります。

近日開催される「DroidKaigi2023」の公式リポジトリのiOS側でのコントリビューションをした際における、AndroidアプリにおいてCoodinatorLayout(NestedScrollを利用した表現) + Tabを組み合わせた際のUI実装に近い表現を、iOSのSwiftUIではどの様に実現したか?という部分に関する自分なりのアプローチと解法をまとめた物になります。

完全な再現とまではいきませんが、Android側で提供されているComponentを利用した表現に近い物を実現したい場面における、シンプルかつ構造がわかりやすい形にするためのアイデアとして参考になれば幸いです。

Fumiya Sakai

September 13, 2023
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. Footprints about Contribution of DroidKaigi 2023
    (第1版) 2023/09/13: After iOSDC LT Night 〜ピクシブ×日経×タイミー〜 @ タイミー様
    (第2版) 2023/09/26: potatotips#84 @ 令和トラベル様
    Fumiya Sakai

    View Slide

  2. 自己紹介
    ・Fumiya Sakai
    ・Mobile Application Engineer
    アカウント:
    ・Twitter: https://twitter.com/fumiyasac

    ・Facebook: https://www.facebook.com/fumiya.sakai.37

    ・Github: https://github.com/fumiyasac

    ・Qiita: https://qiita.com/fumiyasac@github
    発表者:
    ・Born on September 21, 1984
    これまでの歩み:
    Web Designer
    2008 ~ 2010
    Web Engineer
    2012 ~ 2016
    App Engineer
    2017 ~ Now
    iOS / Android / sometimes Flutter

    View Slide

  3. 今年のiOSDCはスポンサーセッション登壇&原稿2本

    View Slide

  4. iOSのUI実装本を執筆しています!
    書籍に掲載したサンプルのバージョンアップや続編等に現在着手中です。
    少しの工夫で実現できるTIPS集やライブラリ表現の活用集をはじめとした、iOSア
    プリ開発の中でも特にUI実装やUIKitを利用した画面の中で特徴を与える様な表現
    という題材に焦点を当てた書籍となっております。
    現在は電子書籍版のみとなります。 こちらは全て¥1,000となっております。
    https://just1factory.booth.pm/
    概要:
    https://book-tech.com/
    価格:
    📖 Booth
    📖 Book Tech

    View Slide

  5. UI実装であると嬉しいレシピブックの最新情報
    UI実装であると嬉しいレシピブックVol.3として昨年10月に商業化しました!
    Still

    WIP
    これまでの同人誌として頒布したものに加えて、Vol.1及びVol.2に頒布したものの
    中で書籍に載せきれなかったものや表現や動きが特徴的でユーザーにもほんの少し
    遊び心を与える様なUI実装を紹介したものをVol.3としています。
    概要:
    これからの構想:
    こちらで購入可能です:
    Amazon / Google Play / Apple Books / KINOKUNIYA / Rakuten BOOKS etc..
    🏊 iOS: SwiftUIを利用したUI実装や動画関連の実装
    🏊 Android: Jetpack Composeの基本やその他気になるUI表現の考察

    View Slide

  6. iOSDC後ではありますが…DroidKaigiのお話しをします
    ちょっとした感じでContributionができたのでその時のお話しを少しだけ
    業務で触れた経験のない技術(KMM)に触れる&去年Contributionができなかったリベンジをしてみたいお気持ち。
    1. 今年こそはDroidKaigiのContributionを試してみることにしました:
    昨年は色々あってできなかったのが正直心残りではあったので、2023年はDroidKaigiアプリのiOS側版で「もし自分ができること
    があれば」という思いもあり挑戦したいとは感じていました。
    2. 自分自身もAndroidを少し触っていた&会社メンバー内でもContribution意欲が高かった:
    業務内でiOS/Androidを並行して開発していた経験もあったことや、会社メンバー間でもDroidKaigiアプリへContribution熱の高
    まりを自分でも感じていたため、そこに乗っかっていくことを決めました。
    3. Kotlin Multiplatform Mobile(KMM)での開発を体験する経験に触れたかった:
    平素の開発ではKotlin Multiplatform Mobile(KMM)は利用してはいなかったものの、関心のある技術の1つではあったので、KMM
    を活用しているDroidKaigiのアプリを通じて新しい技術を学ぶ絶好の機会と思いました。

    View Slide

  7. 去年Contributionができなかった反省を生かして
    2022年の反省点を分析すると下記2点が自分自身のボトルネックであった
    今年は比較的自分に馴染みのある形で作られていた点が個人的にも大きな追い風になりました。
    1. 環境構築部分の難しさ:
    環境構築関連の周辺知識(Gradle・KMM関連とバージョン差異等)が乏し過ぎたので手こずっていた。
    2. iOS側とAndroid側の技術スタックの違い:
    Android側 ⇔ iOS側のコード差異や利用技術スタックの違いを理解するまでに時間がかかった(Androidはこの時はしてない)。
    🤔
    2023年版は環境構築〜Buildまではすんなりと進めた:
    今年は共通ロジック関連部分となるXCFramework作成をfastlaneにまとめていた&Android側のアーキテクチャと似た実装。
    自分の事前知識が足りな過ぎた感…

    View Slide

  8. iOS側(Android)の構造と処理イメージをおさらいする
    DroidKaigiアプリのプロジェクトの全体像を把握する
    KMMで共通化された必要なLogic関連処理をどの様に利用しているか?:
    共通ロジックはKotlinで書かれておりXCFrameworkの形で提供されている & InterfaceはObjective-C製である点。
    必要なLogicが共通化されている
    KMM Common Logic (Kotlin) Backend
    Swift

    (SwiftUI)
    Kotlin

    (Jetpack Compose)
    Objective-C

    Interface
    with

    Ktrofit
    ViewModel + State ① iOS側は Objective-C Framework
    XCFramework
    ③ 更新があったら再生成を実行
    ② Logic自体はKotlinで書いている

    View Slide

  9. DroidKaigiのRepositoryから探る要点
    READMEに記載されているArchitectureの情報や構成の情報がヒントになる
    DraidKaigi2023のリポジトリ:
    READMEやIssue/PullRequestでのやり取りは基本的に英語ではあるが、そこは恐れる必要はないと思います。
    https://github.com/DroidKaigi/conference-app-2023
    Android版のFigma有

    View Slide

  10. 今回自分がContributionを行った部分の概要
    今回はiOS側で起票されていたものの中でUI実装関連部分に挑戦しました
    UI関連表現部分が中心ではあるものの、Android側での実装と見比べながら進める方針で進めました。
    1. 細かなUI関連の表記やリンク先ボタンに関する対応:
    Android側とも見比べてみて、Issue起票当時で対応が必要そうな部分を優先的に実施しました。

    (例)DarkModeの考慮が漏れていた部分、地図表示用UniversalLink対応、発表時の言語表記部分対応等の細かな物が中心。
    2. 検索画面におけるタイトル内で検索キーワードと一致した部分にハイライトを施す対応:
    以前のサンプル開発に取り組んだ際に類似した形の実装経験があったので、今回も応用できるかと思い立ったので、こちらも挑
    戦しました。結論から言えば、AttributedTextを利用する事で実現できました。
    3. TOP画面にあるタブ切り替えAnimation関連部分&スクロール変化を考慮した折りたたみ対応:
    一番最初に取り組んだ部分でもあり、今回のContributionの中でも個人的に思い入れがあった部分です。既存のView要素から
    Animationを利用した表現を加える余地がある部分に対して、調整対応を実施しました。

    View Slide

  11. 入力した検索キーワードに含まれる文言をハイライト
    検索結果画面構造次第ですがAttributedStringを活用することで実現可能
    private func addHighlightAttributes(title: String, searchWord: String) -> AttributedString {

    var attributedString = AttributedString(title)

    if let range = attributedString.range(of: searchWord, options: .caseInsensitive) {

    attributedString[range].underlineStyle = .single

    attributedString[range].backgroundColor = AssetColors.Secondary.secondaryContainer.swiftUIColor

    }

    return attributedString

    }
    Text(addHighlightAttributes(title: timetableItem.title.currentLangTitle, searchWord: searchWord ))


    タイトルを表示するText部分
    タイトル表示内容と検索文字列を渡す
    入力したテキストを反映する
    検索キーワードと一致するものに背景と下線を付与

    View Slide

  12. スクロールに連動した日付選択タブの動きを作る(1)
    最初は自分が気付いた箇所の小さな改善がきっかけでしたが気がついたら…
    ※本発表ではiOSでNestedScrollView+Tabの様な挙動をする部分の概要をお話しできればと思います。
    対応したPullRequestはこちら:
    ① 最初はHeaderボタンへのAnimationを付与する
    Android側で実現している動きに近いものを作る
    Androidの様な動作にしたいissueを発見
    ② TOP画面部分のScrollViewに調整を追加する
    Animation実行時に固まる事が発覚する
    ③ 調査&その時に発見した小さな改善をする

    View Slide

  13. スクロールに連動した日付選択タブの動きを作る(2)
    Android側での実装をまずは調査しタブ部分がどの様な構造かを読み解いてみる
    SwiftUI側では類似要素は提供されていませんが、擬似的な背景の動きはAnimationで実現できそうでした。
    1. TimetableTabRow:
    2. TimetableTab:
    内部ではTabRowを利用した実装
    Android側 (Jetpack Compose) での実装ポイント
    内部ではTabを利用した実装
    基本的な実装はTabRow+Tabの組み合わせで実現する
    これを踏まえてiOS側でどう実現するか?
    3. TimetableSheetContentScrollState:
    🍎 iOS側の構造はButtonを利用していた形でした

    現在時点でのScroll変化量を保持しているState

    View Slide

  14. スクロールに連動した日付選択タブの動きを作る(3)
    Animationを伴う背景部分とButton配置部分を分離してGeometryReaderを利用
    3つ横並びのButton要素に定義したModifier:
    .background(alignment: .center) {

    GeometryReader { geometry in

    Capsule()

    .fill(AssetColors.Primary.primary.swiftUIColor)

    .frame(

    width: calculateButtonWidth(deviceWidth: geometry.size.width),

    height: calculateButtonHeight()

    )

    .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10)

    .animation(.easeInOut(duration: 0.16), value: selectedDay)

    }

    }
    private func calculateDynamicTabHorizontalOffset(deviceWidth: CGFloat) -> CGFloat {

    let buttonAreaWidth = calculateButtonWidth(deviceWidth: deviceWidth)

    // Get the index value corresponding to `selectedDay` and use it for calculation

    let indexBySelectedDay = getIndexBySelectedDay()

    return buttonAreaLeadingMargin + (betweenButtonMargin + buttonAreaWidth)

    * CGFloat(indexBySelectedDay)

    }
    private func calculateButtonWidth(deviceWidth: CGFloat) -> CGFloat {

    // Calculate button width considering related margins

    let excludeTotalMargin = calculateExcludeTotalMargin()

    return (deviceWidth - excludeTotalMargin) / CGFloat(buttonsCount)

    }
    この様に分離して考えています
    14 15 16
    Day1 Day2 Day3
    Slide Animation




    ① X軸方向のOffset値をGeometryReaderより算出
    ② ボタン背景の幅をGeometryReaderより算出

    View Slide

  15. スクロールに連動した日付選択タブの動きを作る(4)
    デバイス幅等の固有値を利用した位置計算時には回転時の考慮に注意する
    ① iPad (Portrait & Landscape): ② iPhone (Portrait & Landscape):
    calculateButtonWidth(deviceWidth: geometry.size.width )
    GeometryReaderを利用してButton要素幅を計算する部分 回転に伴い値が変化する

    View Slide

  16. スクロールに連動した日付選択タブの動きを作る(5)
    配置要素の間隔等も考慮した位置関係を算出する際は見通しが良い状態にする
    特にMargin値・Button要素配置数・Index値等の計算に活用するものを整理する:
    private func calculateDynamicTabHorizontalOffset(deviceWidth: CGFloat) -> CGFloat {

    let buttonAreaWidth = calculateButtonWidth(deviceWidth: deviceWidth)

    // Get the index value corresponding to `selectedDay` and use it for calculation

    let indexBySelectedDay = getIndexBySelectedDay()

    return buttonAreaLeadingMargin + (betweenButtonMargin + buttonAreaWidth)

    * CGFloat(indexBySelectedDay)

    }
    private func getIndexBySelectedDay() -> Int {

    Int(selectedDay.ordinal)

    }
    private func calculateButtonWidth(deviceWidth: CGFloat) -> CGFloat {

    // Calculate button width considering related margins

    let excludeTotalMargin = calculateExcludeTotalMargin()

    return (deviceWidth - excludeTotalMargin) / CGFloat(buttonsCount)

    }
    private func calculateExcludeTotalMargin() -> CGFloat {

    let totalBetweenButtonMargin = betweenButtonMargin * CGFloat(buttonsCount - 1)

    return buttonAreaLeadingMargin + buttonTrailingMargin + totalBetweenButtonMargin

    }
    // Define margin values to calculate horizontal position

    // (for capsule rectangle)

    private let buttonAreaLeadingMargin = 16.0

    private let buttonTrailingMargin = 16.0

    private let betweenButtonMargin = 8.0
    // Define all button count to calculate horizontal position

    // (for capsule rectangle)

    private var buttonsCount: Int {

    Int(DroidKaigi2023Day.values().size)

    }

    View Slide

  17. スクロールに連動した日付選択タブの動きを作る(6)
    Android側での実装や動きを参考にしながらiOS側でのUI構造と見比べてみる
    Scrollの変化量に応じてTab部分の高さと表記が変化する表現:
    NestedScrollに関する公式ドキュメント
    https://techblog.yahoo.co.jp/android/androidcoordinatorlayout/
    AndroidのCoordinatorLayoutを使いこなして、モダンなスクロールを実装しよう
    https://qiita.com/iwata-n/items/e7c5e8db0f9fbb8c288b
    Jetpack composeでCoordinatorLayoutを実現する
    相当するComponentが存在しない
    似た動きを実現するには…🧐

    View Slide

  18. スクロールに連動した日付選択タブの動きを作る(7)
    Android側のView構造を整理した上でiOS側で実現可能な手段を探す
    TimetableScreen.kt
    TimetableHeader.kt
    TimetableSheet.kt
    nestedScrollを利用して変化量を反映する
    ScrollViewで同様の処理は可能か?
    🍎 Offset値取得+Animationの組合せ
    Header位置を固定
    画面のおおもとの部分 Scroll変化量に合わせた処理

    View Slide

  19. スクロールに連動した日付選択タブの動きを作る(8)
    Scroll変化量を取得する必要があるのでScrollViewを拡張するアプローチ
    GeometryReader & PreferenceKey の組み合わせ:
    public struct ScrollViewWithVerticalOffset: View {

    let onOffsetChange: (CGFloat) -> Void

    let content: () -> Content

    public init(

    onOffsetChange: @escaping (CGFloat) -> Void,

    @ViewBuilder content: @escaping () -> Content

    ) {

    self.onOffsetChange = onOffsetChange

    self.content = content

    }

    // ポイント: ScrollView(.vertical) 内部でGeometryReaderを利用する

    // 👉 Scroll変化量を取得できる様な形にしている

    }
    // ① body部分

    public var body: some View {

    ScrollView(.vertical) {

    offsetReader

    content()

    .padding(.top, 0)

    }

    .coordinateSpace(name: "frameLayer")

    .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)

    }
    // ② Offset値読み取り処理部分

    private var offsetReader: some View {

    GeometryReader { proxy in

    Color.clear

    .preference(

    key: OffsetPreferenceKey.self,

    value: proxy.frame(in: .named("frameLayer")).minY

    )

      }

    .frame(height: 0)

    }

    }
    private struct OffsetPreferenceKey: PreferenceKey {

    static var defaultValue: CGFloat = .zero

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}

    }

    View Slide

  20. スクロールに連動した日付選択タブの動きを作る(9)
    画面全体構造における基本方針は「ZStack & ScrollView & LazyVStack」の構成
    全体を構成するTimetableView.swiftにおけるScrollViewのOffset値と関連するAnimation処理の抜粋:
    ZStack(alignment: .topLeading) {

    ZStack(alignment: .top) { // ※ …(画面上部に固定表示される要素を記述)… }

    ScrollViewWithVerticalOffset(

    onOffsetChange: { offset in

    shouldCollapse = offset < verticalOffsetThreshold

    },

    content: {

    Spacer().frame(height: 130)

    LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {

    Section(

    header: TimetableDayHeader(

    selectedDay: viewModel.state.selectedDay,

    shouldCollapse: shouldCollapse,

    onSelect: { [weak viewModel] in

    viewModel?.selectDay(day: $0)

    }

    )

    .frame(height: shouldCollapse ? 53 : 82)

    .animation(.easeInOut(duration: 0.08), value: shouldCollapse)

    ) { // ※ …(一覧表示要素を記述)… }

    }
    // Determines whether or not to collapse.

    private let verticalOffsetThreshold = -142.0

    // When offset value is exceed the threshold, TimetableDayHeader collapse with animation.

    @State private var shouldCollapse = false
    TimetableHeader.swiftの動き
    15 16
    Day2 Day3
    14
    Day1
    Day2 Day3
    Day1
    Collapse or Expand
    ① 取得したOffset値がしきい値を超えたかどうかを見る
    ② shouldCollapse値の変化でAnimation発火

    View Slide

  21. スクロールに連動した日付選択タブの動きを作る(10)
    Header要素内部の表示についてもOffset値を基準として表示内容を変更する
    .background(alignment: .center) {

    GeometryReader { geometry in

    Capsule()

    .fill(AssetColors.Primary.primary.swiftUIColor)

    .frame(

    width: calculateButtonWidth(deviceWidth: geometry.size.width),

    height: calculateButtonHeight()

    )

    .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10)

    .animation(.easeInOut(duration: 0.16), value: selectedDay)

    }

    }
    TimetableDayHeader(

    selectedDay: viewModel.state.selectedDay,

    shouldCollapse: shouldCollapse,

    onSelect: { [weak viewModel] in

    viewModel?.selectDay(day: $0)

    }

    )
    Button {

    onSelect(day)

    } label: {

    VStack(spacing: 0) {

    Text(day.name)

    .textStyle(TypographyTokens.labelSmall)

    if !shouldCollapse {

    Text("\(day.dayOfMonth)")

    .textStyle(TypographyTokens.headlineSmall)

    .frame(height: 32)

    }

    }

    .padding(shouldCollapse ? 6 : 4)

    .frame(maxWidth: .infinity)

    .foregroundStyle( // ※selectedDayに応じて背景色を変化 )

    }
    Button背景Modifier
    Button要素本体
    変数: shouldCollapseを元に高さを調節

    View Slide

  22. 補足1. KMMの雰囲気を掴むための参考資料
    実際に導入した事例や過去のiOSDCでの資料が理解の助けになりました
    登壇資料:
    https://briancoyner.github.io/articles/2021-10-12-reorderable-collection-view/
    KMMを使って感じたPros/Cons / Pros/Cons experienced using KMM
    https://speakerdeck.com/teamlab/kotlin-multiplatform-mobile-for-ios-iosdc2022
    Kotlin Multiplatform Mobile
    で
    iOSとAndroidの実装差異を無くす
    解説ブログ:
    https://developers.freee.co.jp/entry/reflections-on-using-kotlin-platform-at-freee
    Kotlin Multiplatformを運用してみた開発とその振り返り
    https://www.m3tech.blog/entry/kmm-brainfuck-app
    Kotlin Multiplatform Mobileを使ってBrainf*ckエディタアプリを作る

    View Slide

  23. 補足2. UI表現を設計する際にまとめたノート
    特に既存のUIに調整対応を加える部分については要点をまとめる様にしました
    UI上での振る舞いや現在のUI構造における調整対象箇所の概要を言語化する:
    設計2: https://twitter.com/fumiyasac/status/1695835586719044047
    設計1: https://twitter.com/fumiyasac/status/1693501752811917546
    CoodinatorLayoutの様な動き 検索ハイライト機能

    View Slide

  24. まとめ
    今回のContributionを通じて自分自身が勉強になった点も多くありました
    まずは疑問点をIssue内で質問したり、簡単な点の修正対応等から始めてみると良さそうです。
    1. Android側のUI実装や機能と見比べながらiOS側の実装を進めていく点:
    Androidアプリ側の実装が先行して進んでいる場合が多かったことや、普段業務でもiOS/Android両方の実装を比較しながら並行
    して進めていたので、その際の経験を活かしながら進む事ができた様に思います。
    2. 共通ロジック処理(sharedで提供されている部分)を利用する場合の注意:
    Kotlinで書かれた共通処理をSwiftで橋渡しする部分では、Objective-Cを使用しているため、Swiftの流儀とは異なる部分があっ
    たので、この点は実装を進めていくにあたって少し最初は慣れない部分でもありました。
    3. 意外にもIssueやPullRequestを介して質問やコミュニケーションはしやすかった:
    基本的には英語のコミュニケーションではあるけれども、起票されたIssueや対応したPullRequestに関しても、質問は結構しや
    しすい感じはあった様に思います。場合によってはX(Twitter)の発言を拾って頂けたりしたのはありがたかったです。

    View Slide

  25. 要点を抜き出した実装サンプル
    https://github.com/fumiyasac/LikeCoodinatorLayoutExample
    LikeCoodinatorLayoutExample:
    Movie Capture Post (X):
    https://twitter.com/fumiyasac/status/1704859590016590043

    View Slide

  26. Thank you for listening !

    View Slide