Slide 1

Slide 1 text

同じ様なUIをiOS/Android間で合わせるヒントNo.2 Swift/Kotlin愛好会 #51 @ ジーズアカデミー様 2024/04/26 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

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

Slide 4

Slide 4 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 5

Slide 5 text

今回は両方のOSで類似した実装をする際のお話しです 画面実装の実例を元にしてiOS/Android両方を効率良く進めるための観点紹介 総括的な感じの事例紹介となってしまいますが、今後のヒントになれば嬉しく思います。 1. 言語やプラットフォームの違いを理解しながら実装を進める: 今回の事例では、iOS/Androidアプリでの画面実装を考えてみます。もちろんSwiftとKotlinを利用したネイティブ開発なので大 きな違いはあれども、その中から共通点や相違点を探る姿勢がまずは大切だと感じています。 2. 共通点や相違点を見極めながら進めていくと理解が深まる: まずは共通点や相違点を見比べてながらコードを読み進めていくと「考え方のヒント」を見出せる様になると思います。 (iOSで言う所の●●●はAndroidでは▲▲▲になるので、こんな感じになるかなという気付きを大切にする) 3. 特にLayout関連処理やGraphQL関連処理に触れた際の所感: 最近の業務内ではiOSはSwiftUI/UIKit・AndroidはXML Layout/Jetpack Compose・BackendはGraphQLを活用しながら開発を実践し ているので、所感や感想を含めてお互いの実装の類似点・相違点やヒントを見つけ出すポイントを述べられればと思います。

Slide 6

Slide 6 text

この様な画面実装を題材として処理を考えてみます 作品写真をInstagramの様に表示してその中に広告作品を混ぜ込む様な場合 大きな写真と小さな写真が混在状態 + Sectionに応じた規則性: 一見すると難しそうな形ではあるが、規則性に注目すると考えやすくなる。 必ず規則が担保できるか? 基本的には特定のIndex値に該当する取得結果を広告結果に置き換える方針とする 結論: 24件未満(主に終端ページ)以外では概ね順番の担保は可能 奇数Page番号 偶数Page番号 実装時に考慮したいポイントと考え方: 取得結果 … 広告結果 … 1ページあたり最大24件のPagination処理で作品写真を表示 ①広告作品は必ず1件取得 ②ない場合は従来の写真結果 取得結果 + 広告結果の形で1ページに収める場合は前の件数を考慮する必要があった

Slide 7

Slide 7 text

iOS・Androidではどの様な方針を取り得るか? UIKit・SwiftUIでも方針は異なる&Android View・Jetpack Composeでも同様 iOS: UIKit/SwiftUI・Android: Android View/Jetpack Compose で実装する際のアイデアをまとめてみる (1) iOS (2) Android UIKitを利用する場合 Jetpack Composeを利用する場合 UICollectionViewLayoutを継承した独自クラスの実装 UICollectionViewCompositionalLayoutを活用した実装 構成方針が全く異なっていた LazyColumnとPaging3を利用した実装…?? Grid型のLayoutを実装したOSS等を活用した実装…?? 普段から扱い慣れていたのでイメージがすぐ掴めた 🌾 UICollectionViewCompositionalLayoutを利用 最初は全くイメージが湧かずに困惑してしまった 😗 特にPaging3での実装を理解するのに苦戦した

Slide 8

Slide 8 text

まずはAndroidのStaggeredGridLayoutManagerで考える Pinterestの様なWaterfallGrid型のLayoutが実現可能なので使えるかを検証 結論: StaggeredGridLayoutManagerだけでは実現が難しそうであった… 🙆 OK 🙅 NG 列の指定が基準になる点がポイント val staggeredGridLayoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) recyclerView.layoutManager = gridlayoutManager 1列目 2列目 列が定まらない ❌ 第1引数: 行数

Slide 9

Slide 9 text

実はこう考えてみると心が軽くなりました 3パターンのSectionがある形だと考えてみてはどうだろうか? 1つのSectionでのLayoutと考えてしまった事が原因でした: 1 Section have 3 items. … Section … Item この様に1つのSection単位で考える様にすると考えやすくなると思います。 この様に考えるメリット UICollectionViewCompositionalLayoutを利用した形での実装方針は立てやすい。 3個入りでLayoutが異なる3パターンのSectionと捉える(最悪3個で1つのCell要素と考えるのもOK) 宣言的UI(SwiftUI・Jetpack Compose)だともっと考えやすくなる 部品構造自体もSwiftUI・Jetpack Composeを利用する事で直感的に組み立てられる。 ログ送信等でデータ取得順番が必要な場合はView表示用で利用するObjectに予め格納すれば良い。

Slide 10

Slide 10 text

Section要素の概要とレイアウトを組み立てる際の概要 全体で見ると難しそうだが要素を丁寧に分解してみると考えやすくなる 表示要素3つを引数で渡す想定で考えてみると良い: Jetpack Compose階層構造: ConstraintLayout - AsyncImage (左側の大きな画像) - HorizontalDivider (縦線をPaddingに合わせて) - AsyncImage (右上側の小さな画像) - Divider (横線をPaddingに合わせて) - AsyncImage (右下側の小さな画像) (参考) SwiftUI階層構造: HStack (with Padding) - AsyncImage (左側の大きな画像) - VStack (with Padding) - AsyncImage (右上側の小さな画像) - AsyncImage (右下側の小さな画像) この様に1つの要素内に6 つの部品があると考える イメージを持つと良い。 https://fvilarino.medium.com/creating-a- dynamic-grid-in-jetpack-compose-35f6cb71fd55 https://www.youtube.com/watch?v=txQqhuSZ9xQ SwiftUI 2.0 Compositional Layout - Instagram Feed Layout - SwiftUI Tutorials Width・Height 計算は別途必要

Slide 11

Slide 11 text

Paging3を利用したPagination処理と組み合わせる Paging3を利用したアーキテクチャの概要とデータ取得処理を結合する箇所 Screen View 公式Documentで推奨しているPaging3利用時のArchitecture: https://developer.android.com/topic/libraries/architecture/paging/v3-overview ViewModel UseCase PagingSource Repository Infrastructure 自分が経験した際のArchitecture概要: UseCase内部でPagingSourceを 初期化する様な形にする。 ※実装ポイント① ※個人的に事前に知っておくと良さそうな部分 1. データ取得処理を実際に実行する場所はどこ? PagingSouce内部でRepositoryクラスの処理を実行する。 Pagination処理はPagingSouceにお任せするイメージ。 2. Flow>部分の処理は何をしている? 取得したPagingをFlow型で返す&Paging処理の設定をする。 PagingSourceで取得結果と広 告結果をMergeして返却する。 ※実装ポイント② ViewModel内ではFlow>の作成処理をする。 画面では.collectAsLazyPagingItems()で取得して利用する。 ※実装ポイント③

Slide 12

Slide 12 text

class FoodPhotoViewModel( private val useCase: FoodPhotoUseCase ): ViewModel() { … ※今回はpaginationに関する処理を抜粋しています override val foodPhotoStream: Flow> by lazy { Pager(PagingConfig(pageSize = 24, initialLoadSize = 24)) { useCase.foodPhotoPagingSource() }.flow.cachedIn(viewModelScope) … } Paging3を利用したPagination処理例と表示関連部分 Section単位でのList要素がFlowで返却される&Section値でレイアウトが決定 (参考1) https://tech.pepabo.com/2021/10/18/android-paging3/ (参考2) https://speakerdeck.com/ticktaku77/shi-jian-paging-3 @Composable fun CatListScreen(viewModel: CatViewModel) { val pagingItems = viewModel.foodPhotoStream.collectAsLazyPagingItems() SwipeRefresh( state = pagingItems.loadState.refresh is LoadState.Loading, onRefresh = { pagingItems.refresh() } ) { LazyColumn { // Section毎のView要素を表示する … ※以降にエラー発生時のHandling処理を追加する } } } Section: 0 Section: 1 Section: 2 Section: 3 ① LeadingLarge ② TrailingLarge ③ SmallSet 表示データを3つ格納した List要素を更にListに格 納したものが返却される Sectionを選出基準はどの様に定める? ① Section / 4 = X とする。 ② Y = X % 2 の結果で判定する。 Y = 0 Y = 1

Slide 13

Slide 13 text

PagingSource内部で実行するデータ取得〜Merge処理 取得結果と広告結果を取得した後に特定Index値の部分を広告結果に置き換える Kotlinの「chunked(size: Int)」を利用する事でSection用データを作成する: (1) まずは従来通りの写真取得結果 + 広告結果をRequest Coroutineを利用して表示に必要なデータを取得する。 (2) Page番号(現在何Page目か?)を基にして該当Index値を広告作品に置換 (3) 広告作品を混ぜた一覧List要素をchunkedする ※1. 広告結果が取得できない時は、そのまま写真取得結果を表示する。 ※2. 複数のRequestが必要な場合には、ZipないしはCombineLatest等を活用する。 奇数Page番号 偶数Page番号 Section要素内の格納順番と取得できた写真取得結果のIndex値を考慮する ※1. 24件より少なく該当Indexがない場合は置換処理は実施しない 1Page分の表示内容リスト:[[取得結果3個分], [取得結果3個分], … ] ※1. 取得結果3個分の中に広告作品が混ざっている形となっている

Slide 14

Slide 14 text

OnScrollListener利用時と比較してのメリット CoroutineやFlowの対応はもとより、正確な読み込み状態に応じた処理が可能 OnScrollListener スクロールの最下部へ到達し た際に次のデータを読み込む 処理を実行。 PagingSourceに読み込みや状態管理を委譲できる: OnScrollListenerを利用時は自分で状態管理が必要 ※1. 場合により、onScrollStateChangedやonScrolled等も利用する。 ※2. 判定条件を算出するための値の関係性が難しい場合もある。 (参考1) https://hiropoppo.hatenablog.com/entry/kotlin_recipe/endless override val foodPhotoStream: Flow> by lazy { Pager(PagingConfig(pageSize = 24, initialLoadSize = 24)) { useCase.foodPhotoPagingSource() }.flow.cachedIn(viewModelScope) } Flowとの相性はOnScrollListenerより良い: (参考2) https://devblog.thebase.in/entry/2021/09/01/110000 (参考3) https://techblog.yahoo.co.jp/entry/2023022130414554/

Slide 15

Slide 15 text

補足: UICollectionViewCompositionalLayout利用時 private func createExampleSectionLayouts() -> NSCollectionLayoutSection { // 1. Itemのサイズ設定 // MEMO: 全体幅2/3の正方形を作るために左側の幅を.fractionalWidth(0.67)に決める let twoThirdItemSet = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.67), heightDimension: .fractionalHeight(1.0))) twoThirdItemSet.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5) // MEMO: 右側に全体幅1/3の正方形を2つ作るために高さを.fractionalHeight(0.5)に決める let oneThirdItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5))) oneThirdItem.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5) // MEMO: 1列に表示するカラム数を2として設定し、Group内のアイテムの幅を1/3の正方形とするためにGroup内の幅を.fractionalWidth(0.33)に決める let oneThirdItemSet = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1.0)), repeatingSubitem: oneThirdItem, count: 2) // 2. Groupのサイズ設定 // MEMO: leadingItem(左側へ表示するアイテム1つ)とtrailingGroup(右側へ表示するアイテム2個のグループ1個)を合わせる let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.33)), subitems: [twoThirdItemSet, oneThirdItemSet]) // 3. Sectionのサイズ設定 let section = NSCollectionLayoutSection(group: group) // MEMO: HeaderとFooterのレイアウトを決定する let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) section.boundarySupplementaryItems = [header] section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0) return section } ※この形になります Item/Group作成に注意

Slide 16

Slide 16 text

参考:過去にはこの様なライブラリがありました 従来までのiOS・Androidの処理においても結構しんどいレイアウトだった歴史 Brueprint: flexbox-layout & SpannedGridLayoutManager: https://github.com/noowenz/SpannedGridLayoutManager https://github.com/google/flexbox-layout https://github.com/zenangst/Blueprints ※こちらは過去の私の著書でも紹介したライブラリです。

Slide 17

Slide 17 text

まとめ iOS/Androidの両方を実装を見比べて見ると、実はそう遠くない部分もある 今後ともメインはiOSに置きながらもAndroidについてもアウトプットができる様に頑張ります。 1. 言語・コンポーネント・アーキテクチャの違い等はあるけど類似点・共通点を探してみる: 実装のイメージが類似している場合には実装方針を見抜くチャンスと捉える様にしています。個人的にはこれまでの経験の中か ら「実装のテイストが似ている部分」を見つけ出す様にするために、現在は両方進んでコードを読む様にしています。 2. iOS/Android間で明らかに考え方が異なる部分は念入りに仕様調査をする: 以前の業務でも苦戦した部分はDIコンテナ関連処理と動画再生関連部分でした。両方進んでコードを読み進めていく際に、考え 方が大きく異なる部分については、サンプル実装をしているOSS等をヒントに基本理解を進める様にしています。 3. AndroidのPaging3内の処理やレイアウト改修を通じた知見と収穫: Paging3を利用した際には、自前でこの部分を実装する必要があるiOSとは大きく異なる点を押さえておく事で、大体の工数把握 や実装前のデザイナーとの調整等の場面で役立つ場面もあったので、この様な部分は引き続き大切にしていきたいです。

Slide 18

Slide 18 text

Thank you for listening !