Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

自己紹介 ・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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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表現の考察

Slide 6

Slide 6 text

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のアプリを通じて新しい技術を学ぶ絶好の機会と思いました。

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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で書いている

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

入力した検索キーワードに含まれる文言をハイライト 検索結果画面構造次第ですが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部分 タイトル表示内容と検索文字列を渡す 入力したテキストを反映する 検索キーワードと一致するものに背景と下線を付与

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

スクロールに連動した日付選択タブの動きを作る(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より算出

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

スクロールに連動した日付選択タブの動きを作る(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) }

Slide 17

Slide 17 text

スクロールに連動した日付選択タブの動きを作る(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が存在しない 似た動きを実現するには…🧐

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

スクロールに連動した日付選択タブの動きを作る(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) {} }

Slide 20

Slide 20 text

スクロールに連動した日付選択タブの動きを作る(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発火

Slide 21

Slide 21 text

スクロールに連動した日付選択タブの動きを作る(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を元に高さを調節

Slide 22

Slide 22 text

補足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エディタアプリを作る

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Thank you for listening !