Share
Embed
DroidKaigi2024公式アプリiOS側Contribution裏話
10月11日にSTORES様で開催されたDroidKaigi 2024 おつかれさまパーティーの登壇資料になります。
DroidKaigi2024年のiOSアプリに関する話題とContribution内容の振り返りをまとめています。
Fumiya Sakai
October 11, 2024
Transcript
23 slides
-
自己紹介 ・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 -
UI実装であると嬉しいレシピブックの最新情報 UI実装であると嬉しいレシピブックVol.3として2022年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表現の考察 -
今回はDroidKaigiのiOSアプリのお話しをします DroidKaigi2024年のiOSアプリに関する話題とContribution内容の振り返り 2023年〜DroidKaigi公式アプリのContributionを実施していますが、2024年に実施した内容をピックアップしました。 1. 毎年のモバイルアプリ関連技術トレンドを知る事ができる絶好の機会: DroidKaigiの公式アプリで利用している技術スタックは、良い意味で尖った選定をされている印象が以前からありました。実際 にリポジトリをクローンして動かしながら中の実装を見るだけでも参考になる点が多いです。 2. 環境構築関連や細かな改善対応に挑戦してみる所からのスタート: KMP等に馴染みがないと最初は環境構築や動かす所で苦戦していました。最初にContributionを始める際には、環境構築時のトラ
ブルシューティングや細かな改善対応から始める様にしています。 3. Androidでの実装を参考にしてiOSの実装へ応用してみる: 2023年のContributionではAndroidでは標準Componentの機能で提供されている表現(NestedScrollViewに近い形のもの)を SwiftUIで実装する試みをした経験があったので、2024年もできる余地があるかを試してみました。 -
2024年のDroidKaigiのContribution例(環境構築) Issueの方は自分の手元で環境構築を実施時に遭遇した調査内容を記載しました 普段はKMPを利用していないので想定以上に苦戦: 起票Issue集: 要約するとこの様な内容 Rosetta2を有効にしていると、$ make bootstrapコマンドに失敗するよ https://github.com/DroidKaigi/conference-app-2024/issues/616 iOSプロジェクトをBuildすると、JVMバージョンが合っていないと言われるよ
https://github.com/DroidKaigi/conference-app-2024/issues/616 -
2024年のDroidKaigiのContribution例(環境構築) Rosetta2を有効にしていると、$ make bootstrapコマンドに失敗する件の解決 Rosetta2の場合はこうすれば解決できました: 🌾 こちらは自分の環境依存の可能性もあると思います… Error Message in
Console: Cannot install under Rosetta2 in ARM default prefix … に注目 自分の環境が諸般の事情によりRosetta2を有効にした事に気が付く ✅ My Operation Step: 1. Check current location : % brew --prefix → /opt/homebrew 2. Remove /opt/homebrew : % sudo rm -r /opt/homebrew 3. Reinstall Homebrew : % /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/ Homebrew/install/HEAD/install.sh)" 4. Check new location : % brew --prefix → /usr/local あくまで暫定措置 -
2024年のDroidKaigiのContribution例(環境構築) iOSプロジェクトをBuildすると、JVMバージョンが合っていない件の解決 SDKMANでJVMを指定したのに発生してしまった: Error Message in Console: 1. まずはJVM17をSDKMANを利用してインストールを確認 2.
Android Studioの設定を色々触ってみたけどうまくいかず… Xcode Log 2023年にContributio時に設定した昔のJVMが指定されている… My Operation Step: 3. Xcode内のLocationsにSDKMANのパスを指定するとうまく行った ✅ -
個人的なDroidKaigi公式アプリの紐解き方 内部アーキテクチャを相違点等を押さえながらAndroidでの実装を参考にする iOS/Android間の相違点や共通点を参考にする: Recap about Architecture Point: 1. 共通ロジック部分・内部処理等はAndroidの実装を参考にする 2.
UI実装関連についてはSwiftUIにおける違いを知っておく Approach Guidance: Swift / Kotlin 1. Common Business Logic 2. Unidirectional Architecture - Kotlin Multi Platform & SKIE - iOS: TCA / Android: Rin https://github.com/takahirom/Rin 3. UI Structure - iOS: SwiftUI / Android: Compose Mutliplatform https://github.com/pointfreeco/ swift-composable-architecture Like Redux or Flux - Localizationやリンク遷移等はすぐに対応可能 - 各種Componentで提供されていない表現への対応 - UIの状態管理をArchiteにお任せするかどうか - Animationや動きをどこまで合わせた方が良いか - KMPで処理すべきかMobileで処理すべきか問題 - SKIEの自動生成内容が結構助けになる Rin: TCA: -
検索カテゴリー用のChipのバグ対応 一見難しそうに見えたけど実現したい挙動に関する情報を整備する TCA側の修正が必要かの見極め: 構築に必要な値は一括で取得する方針であった: Search Category Chip 1. 取得データ処理に関する処理はReducerに注目 2.
選択したCategoryはどの様に取り扱うか? - 一括取得したデータを元にフィルタリングする方針 - TCAのState内に選択されている項目を保持している 3. View要素の組み立て処理における注意点 - Chipを組み立てる処理はCategory一覧を利用して構築する 微妙に属性が違った Enumを利用してChipを作っている処理を参考に別途新しく作成 -
検索カテゴリー用のChipのバグ対応 TCAのReducer内部で選択肢の取得はできていたため表示要素処理を個別に作成 searchCategoryFilterChip( allCategories: store.timetable?.categories ?? [], selection: store.selectedCategory, defaultTitle:
String(localized: "カテゴリ", bundle: .module), onSelect: { store.send(.view(.selectedCategoryChanged($0))) } ) private func searchCategoryFilterChip( allCategories: [TimetableCategory], selection: TimetableCategory?, defaultTitle: String, onSelect: @escaping (TimetableCategory) -> Void ) -> some View { Menu { ForEach(allCategories, id: \.id) { category in Button { onSelect(category) } label: { HStack { if category == selection { Image(.icCheck) } Text(category.title.currentLangTitle) } } } } label: { SelectionChip( title: selection?.title.currentLangTitle ?? defaultTitle, isMultiSelect: true, isSelected: selection != nil ) {} } } public var body: some ReducerOf<Self> { Reduce { state, action in switch action { case let .view(viewAction): switch viewAction { // … 省略 … case let .selectedCategoryChanged(category): state.filters = state.filters.copyWith( categories: category.map { [$0] } ?? [] ) return .none // … 省略 … } } } // MEMO: All Category can get from timetable TimetableCategories get timetable model. // (TimetableCategory don't have to conform to Selectable protocol.) Make Categories in TCA Reducer. 表示内容をfilter @ObservableState public struct State: Equatable // 👉 stateの値を作成してView要素で利用 var selectedCategory: TimetableCategory? { filters.categories.first } 状態変化 View構築 -
Redux処理とTCA処理の比較(以前の資料より引用) Actionを発行して副作用を伴うReducer処理で新たなStateを作成する流れは同様 1. ReduxでのView更新までの流れ: 2. TCAでのView更新までの流れ: Unidirectionalなデータの流れを作る方針はとても類似しているが副作用に関する考え方が特徴的に感じる。 View要素から実行された Actionを発行する Middleware(副作用)が
処理前後で実行される 該当するAction合致時は 内部処理を利用して別の Actionを発行する Reducer処理内でState内 のPropertyを更新する Middleware(副作用)がな い場合は直接Reducerへ 全体のStateが更新され View要素を更新する View要素から実行された Actionを発行する Effect(副作用)が Reducer内で実行される Reducer内処理において Effectを利用して内部で 別のActionを発行する Reducer処理内でState内 のPropertyを更新する Effect(副作用)がない場 合は直接Reducerへ 全体のStateが更新され View要素を更新する -
タイムテーブルをGrid表示した際の表示崩れ対応 SwiftUI製のGrid表現をするView要素に予期しないMarginができていた 調整が複雑そうに見えるが実はすぐ解決: View要素に適用しているModifierを調整する: Unexpected Margin 1. Grid状のView表示はScrollViewとGridRow & Gridを利用
2. 要素のWidth計算で算出した値は正しそうであった 👉 元々はwidth:で指定していたのでmaxWidth:へ変更 Grid構造の概要 - ScrollView (縦横方向) - GridRow - ForEach (部屋表示) - 縦線 - ForEach (内容表示) - GridRow (時間×部屋) 時間表示 ※条件1: ランチ用表示 ※条件2: セッション一覧 - ForEach(部屋別表示) .frame(maxWidth: 192 * CGFloat(cellCount) + CGFloat(12 * (cellCount - 1))) .frame(height: 153) .frame(width: 40, height: 153) ② Timetable1個あたりの表示要素の最大幅を指定 👉 元々はwidth:の指定がなかったので追加する ① 時刻要素の幅を指定 幅指定Modifier変更 -
お気に入り追加時のハートマークアニメーション対応 Androidで実現する動きが綺麗でしたので、SwiftUIでもできるか試してみた 既存のView構造でどう構築するか?を考える: 直線的な動きならば強引にできるかも: 1. ScrollViewを基準とした位置取得 2. Animation終端位置をGeometryReaderで計算する 👉 画面を基準とした位置はGeometryReaderから算出
Animation概要 👉 DragGestureの処理で代用できないか?を考えてみる ① Button要素では現在の位置が取得できなかった 課題となった部分 ❤ ❤ 理想の動き 現実の動き ❤ ❤ 弧を描く 直線的 👉 お気に入りボタンで追加処理時に位置情報を渡す ② お気に入りに追加した時の考慮ポイント 👉 おおもとの画面をZStackにしてAnimation用View要素と分離する -
お気に入り追加時のハートマークアニメーション対応 タイムテーブル表示要素からタップされた位置を取得するための処理抜粋 // [NOTE] In order to calculate the value
from GeometryReader, it is supported by assigning DragGesture to the Image element instead of Button. HStack { GeometryReader { geometry in // MEMO: Since the coordinate values are based on the inside of the View, ".local" is specified. let localGeometry = geometry.frame(in: .local) Image(isFavorite ? .icFavoriteFill : .icFavoriteOutline) .resizable() .renderingMode(.template) .foregroundColor( isFavorite ? AssetColors.Primary.primaryFixed.swiftUIColor : AssetColors.Surface.onSurfaceVariant.swiftUIColor ) .frame(width: 24, height: 24) .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in // MEMO: The offset value in the Y-axis direction is subtracted for adjustment (decided by device orientation). let adjustedLocationPoint = CGPoint(x: dragGesture.location.x, y: dragGesture.location.y - calculateTopMarginByDevideOrietation()) onTapFavorite(timetableItem, adjustedLocationPoint) }) // MEMO: To adjust horizontal position, I'm subtracting half the size of Image (-12). .position(x: localGeometry.maxX - 12, y: localGeometry.midY) } } let onTapFavorite: (TimetableItem, CGPoint?) -> Void タップ時には該当するTimetableItemと一緒 に.gestureで取得できたGeometryReaderの 値を一覧表示するView要素へ送る。 回転の考慮も必要 👉 基準はこのView内部 👉 .positionではView内部を基準としたX軸&Y軸方向の位置を指定する 👉 最終的に送られるのは画面全体を基準とした位置の値 - DragGestureを利用してボタンタップの様に見せている - Drag終了時位置をClosureで送信(coordinateSpace: .global) -
ZStack { ScrollView { LazyVStack(spacing: 0) { ForEach(store.timetableItems, id: \.self)
{ item in TimeGroupMiniList(contents: item, onItemTap: { item in store.send(.view(.timetableItemTapped(item))) }, onFavoriteTap: { timetableItemWithFavorite, adjustedLocationPoint in store.send(.view(.favoriteTapped(timetableItemWithFavorite))) // MEMO: When "isFavorited" flag is false, this view executes animation. if timetableItemWithFavorite.isFavorited == false { toggleFavorite(timetableItem: timetableItemWithFavorite.timetableItem, adjustedLocationPoint: adjustedLocationPoint) } }) } }.scrollContentBackground(.hidden) .onAppear { store.send(.view(.onAppear)) }.background(AssetColors.Surface.surface.swiftUIColor) bottomTabBarPadding } // MEMO: Stack the Image elements that will be animated using ZStack. makeHeartAnimationView() } お気に入り追加時のハートマークアニメーション対応 タイムテーブル一覧画面の構造は一覧表示と動きを作るための要素を分割する 👉 タイムテーブル一覧表示はScrollView + LazyVStack TimetableItemと一緒に.gestureで取得 できたGeometryReaderの値 @ViewBuilder private func makeHeartAnimationView() -> some View { GeometryReader { geometry in if targetTimetableItemId != nil { Image(systemName: "heart.fill") .foregroundColor( AssetColors.Primary.primaryFixed.swiftUIColor ) .frame(width: 24, height: 24) .position(animationPosition(geometry: geometry)) .opacity(1 - animationProgress) .zIndex(99) } } } ハートマークが画面上を動く様に見せる 👉 ハートマークImage要素を単体で重ねて表示する 👉 @Stateの値変化でAnimationを実行 Animationを実行するために@Stateを更新 -
お気に入り追加時のハートマークアニメーション対応 @ViewBuilder private func makeHeartAnimationView() -> some View { GeometryReader
{ geometry in if targetTimetableItemId != nil { Image(systemName: "heart.fill") .foregroundColor( AssetColors.Primary.primaryFixed.swiftUIColor ) .frame(width: 24, height: 24) .position(animationPosition(geometry: geometry)) .opacity(1 - animationProgress) .zIndex(99) } } } // MEMO: A variable that stores the value of Animation variation. (Only 0 or 1) @State private var animationProgress: CGFloat = 0 // MEMO: Select target targetTimetableItemId & targetLocationPoint (for Animation). @State private var targetTimetableItemId: TimetableItemId? @State private var targetLocationPoint: CGPoint? private func animationPosition(geometry: GeometryProxy) -> CGPoint { // MEMO: Get the value calculated from both the default and .global GeometryReader. let globalGeometrySize = geometry.frame(in: .global).size let defaultGeometrySize = geometry.size // MEMO: Calculate the offset value in the Y-axis direction using GeometryReader. let startPositionY = targetLocationPoint?.y ?? 0 let endPositionY = defaultGeometrySize.height - 25 let targetY = startPositionY + (endPositionY - startPositionY) * animationProgress // MEMO: Calculate the offset value in the X-axis direction using GeometryReader. let adjustedPositionX = animationProgress * (globalGeometrySize.width / 2 - globalGeometrySize.width + 50) let targetX = defaultGeometrySize.width - 50 + adjustedPositionX return CGPoint(x: targetX, y: targetY) } 👉 toggleFavoriteを実行すると@Stateが更新される - animationProgressの値はアルファ値と計算で利用する - タイムテーブルID & タップ位置もnullableの変数で保持する - Animation終了位置算出や要素表示可否の決定をする 👉 GeometryReaderと@Stateで位置を算出する ②位置&アルファ更新 ①表示可否 private func toggleFavorite(timetableItem: TimetableItem, adjustedLocationPoint: CGPoint?) { targetLocationPoint = adjustedLocationPoint targetTimetableItemId = timetableItem.id // MEMO: Execute animation. if targetTimetableItemId != nil { withAnimation(.easeOut(duration: 1)) { animationProgress = 1 } Task { try await Task.sleep(nanoseconds: 1_000_000_000) targetTimetableItemId = nil targetLocationPoint = nil animationProgress = 0 } } } -
弧を描く様なアニメーションにするためには? この点はまだまだ改善できるかも?と思い探究中です Pathアニメーションを利用する事ができれば、かなり近い表現ができるかもしれない… Pathアニメーションないしはkeyframeアニメーションを検討中 今後の展望 現在GeometryReaderでの計算式の部分を改善できれば… https://www.youtube.com/watch?v=RyuwMSUemzc NetflixアプリのOnboarding画面で利用している様な表現 https://www.youtube.com/watch?v=Yz82uI-X98g -
GeometryReaderの計算がとても厄介 - Animation表現のクオリティは妥協せざるを得なかった 🍀 現在感じているこれはちょっとな…と思うポイント - 位置算出の基準を間違えてしまうと失敗しやすい - 表現を含めたデバッグがとても面倒 - 弧を描く様な感じは現状作成する方が難しい -
まとめ OSSのContribution体験を積む以上に取り組む価値があると改めて感じました! 来年もまたContributionができる様に精進致します! 1. 最新の技術スタックに触れる事ができる絶好の機会: iOS/Android共に最新技術スタックやトレンドを積極的に採用している事もあり、キャッチアップをするきっかけとして絶好の機 会であると感じています。手元で挙動をチェックしながら触れられるのでイメージしやすいと思います。 2. iOSプロジェクトでのトラブルシューティングは記録を残すと良い: KMP関連でiOSプロジェクトBuild時に遭遇する不具合やエラーはもしかすると同じ事象で困っている方がいるかもしれないので、
環境構築時の手順やトラブル解消に向けた情報をまとめておくと良さそうです。 3. 粒度や難易度の差はあれどもコードを見比べながら体験できる楽しさ: 細かなバグ改善等に取り組む過程の中で様々な発見がある点はもちろんですが、実務ではなかなかできない様な経験ができたり もするので、この機会を有効活用してインプット&アウトプットに役立つと感じています。