Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

DroidKaigi2024公式アプリiOS側Contribution裏話

 DroidKaigi2024公式アプリiOS側Contribution裏話

10月11日にSTORES様で開催されたDroidKaigi 2024 おつかれさまパーティーの登壇資料になります。

DroidKaigi2024年のiOSアプリに関する話題とContribution内容の振り返りをまとめています。

Fumiya Sakai

October 11, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・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
  2. 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 あくまで暫定措置
  3. 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のパスを指定するとうまく行った ✅
  4. 個人的な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:
  5. 検索カテゴリー用のChipのバグ対応 一見難しそうに見えたけど実現したい挙動に関する情報を整備する TCA側の修正が必要かの見極め: 構築に必要な値は一括で取得する方針であった: Search Category Chip 1. 取得データ処理に関する処理はReducerに注目 2.

    選択したCategoryはどの様に取り扱うか? - 一括取得したデータを元にフィルタリングする方針 - TCAのState内に選択されている項目を保持している 3. View要素の組み立て処理における注意点 - Chipを組み立てる処理はCategory一覧を利用して構築する 微妙に属性が違った Enumを利用してChipを作っている処理を参考に別途新しく作成
  6. 検索カテゴリー用の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構築
  7. 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要素を更新する
  8. タイムテーブルを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変更
  9. お気に入り追加時のハートマークアニメーション対応 Androidで実現する動きが綺麗でしたので、SwiftUIでもできるか試してみた 既存のView構造でどう構築するか?を考える: 直線的な動きならば強引にできるかも: 1. ScrollViewを基準とした位置取得 2. Animation終端位置をGeometryReaderで計算する 👉 画面を基準とした位置はGeometryReaderから算出

    Animation概要 👉 DragGestureの処理で代用できないか?を考えてみる ① Button要素では現在の位置が取得できなかった 課題となった部分 ❤ ❤ 理想の動き 現実の動き ❤ ❤ 弧を描く 直線的 👉 お気に入りボタンで追加処理時に位置情報を渡す ② お気に入りに追加した時の考慮ポイント 👉 おおもとの画面をZStackにしてAnimation用View要素と分離する
  10. お気に入り追加時のハートマークアニメーション対応 タイムテーブル表示要素からタップされた位置を取得するための処理抜粋 // [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)
  11. 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を更新
  12. お気に入り追加時のハートマークアニメーション対応 @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 } } }