Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Footprints about Contribution of DroidKaigi 2023

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.
Avatar for Fumiya Sakai 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を利用した表現に近い物を実現したい場面における、シンプルかつ構造がわかりやすい形にするためのアイデアとして参考になれば幸いです。

Avatar for Fumiya Sakai

Fumiya Sakai

September 13, 2023

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
  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
  3. 去年Contributionができなかった反省を生かして 2022年の反省点を分析すると下記2点が自分自身のボトルネックであった 今年は比較的自分に馴染みのある形で作られていた点が個人的にも大きな追い風になりました。 1. 環境構築部分の難しさ: 環境構築関連の周辺知識(Gradle・KMM関連とバージョン差異等)が乏し過ぎたので手こずっていた。 2. iOS側とAndroid側の技術スタックの違い: Android側 ⇔

    iOS側のコード差異や利用技術スタックの違いを理解するまでに時間がかかった(Androidはこの時はしてない)。 🤔 2023年版は環境構築〜Buildまではすんなりと進めた: 今年は共通ロジック関連部分となるXCFramework作成をfastlaneにまとめていた&Android側のアーキテクチャと似た実装。 自分の事前知識が足りな過ぎた感…
  4. 入力した検索キーワードに含まれる文言をハイライト 検索結果画面構造次第ですが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部分 タイトル表示内容と検索文字列を渡す 入力したテキストを反映する 検索キーワードと一致するものに背景と下線を付与
  5. スクロールに連動した日付選択タブの動きを作る(2) Android側での実装をまずは調査しタブ部分がどの様な構造かを読み解いてみる SwiftUI側では類似要素は提供されていませんが、擬似的な背景の動きはAnimationで実現できそうでした。 1. TimetableTabRow: 2. TimetableTab: 内部ではTabRowを利用した実装 Android側 (Jetpack

    Compose) での実装ポイント 内部ではTabを利用した実装 基本的な実装はTabRow+Tabの組み合わせで実現する これを踏まえてiOS側でどう実現するか? 3. TimetableSheetContentScrollState: 🍎 iOS側の構造はButtonを利用していた形でした + 現在時点でのScroll変化量を保持しているState
  6. スクロールに連動した日付選択タブの動きを作る(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より算出
  7. スクロールに連動した日付選択タブの動きを作る(4) デバイス幅等の固有値を利用した位置計算時には回転時の考慮に注意する ① iPad (Portrait & Landscape): ② iPhone (Portrait

    & Landscape): calculateButtonWidth(deviceWidth: geometry.size.width ) GeometryReaderを利用してButton要素幅を計算する部分 回転に伴い値が変化する
  8. スクロールに連動した日付選択タブの動きを作る(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) }
  9. スクロールに連動した日付選択タブの動きを作る(8) Scroll変化量を取得する必要があるのでScrollViewを拡張するアプローチ GeometryReader & PreferenceKey の組み合わせ: public struct ScrollViewWithVerticalOffset<Content: View>:

    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) {} }
  10. スクロールに連動した日付選択タブの動きを作る(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発火
  11. スクロールに連動した日付選択タブの動きを作る(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を元に高さを調節
  12. 補足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エディタアプリを作る