Slide 1

Slide 1 text

(第1版) 2024/07/14: 神山.swift @ 徳島県神山町 (第2版) 2024/07/17: YUMEMI.grow Mobile #15 @ オンライン こんなUIってSwiftUIでこう作るのか! を解剖してみた 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

(告知) iOSDC Japan 2024へ原稿を2つ寄稿しています! Combineベースの実装をSwift Concurrencyへ少しずつ 置き換えていく際のアイデアとヒントのご紹介 UIKitを利用した複雑な表現を SwiftUIで再現する際の考え方と事例紹介 こちらの 補足解説

Slide 6

Slide 6 text

SwiftUIを利用してどこまでUIKitを置き換えられるか UIKit利用時でも一癖がある様な既存処理をSwiftUIで実現するための工夫 現在のSwiftUIにおけるScrollView関連処理を有効活用する事で、複雑なUI関連処理が可能になる余地は増えています。 1. 情報発信メディア系のiOSアプリで良く見かけるUI事例: UICollectionView(UIScrollView)をタブ型表示部分に利用して、UIPageViewControllerをコンテンツ表示部分へ利用する形が 基本的な方針でした。タブ切り替え時の表現や動きに対して様々な工夫を加える余地があるのも魅力だと感じています。 2. UIKitで実施していたUIScrollViewDelegate関連処理を置き換える: UIViewRepresentable(UIViewControllerRepresentable)を利用して、UIKit&SwiftUIが混在する構造をする方針を取るのも良 いと思います。SwiftUIのみで構築する場合はGeometryReaderやScroll関連のModifierを活用する事になります。 3. 実際にSwiftUIでの実装に取り組んでみた際の所感: 解説動画や教材の内容を参考にしながら、実現する際のポイントになる部分を紐解きながら進める事で得られた事は想像以上に 多くあった様に思います。いわゆる「車輪の再開発」ではありますが、所感やポイントもお伝えできればと思います。

Slide 7

Slide 7 text

対象となるUI実装に関連する基本事項を確認する UICollectionView(UIScrollView)とUIPageViewControllerの組み合わせ UIKitで実現するためのポイント(基本部分): 具体的なUI表現イメージ例: 1. コンテンツ表示部分(UIPageViewControllerで作成された一 覧表示部分)に関する処理概要 2つ全く異なる振る舞いをする要素を1つの画面内で表現 該当Category別にデータ一覧を整理して表示する際には有効 UIPageViewControllerDelegateの didFinishAnimating 処理時 にIndex値に応じてタブ位置を更新する。 2. タブ型表示部分(UICollectionView or UIScrollViewを利用 して並べられた要素一覧部分)に関する処理概要 func scrollViewDidScroll(_ scrollView: UIScrollView) ① タブ型表示要素をタップすると、該当コンテンツ要素を表示。 ② コンテンツをスワイプ移動すると、タブ型表示要素が連動。

Slide 8

Slide 8 text

(余談)このUI実装に無限循環スクロールをする場合 前述した基本事項に加えて更に無限循環をするための配慮が必要になる UICollectionView関連の 位置&Index調整処理 ポイントを整理するとこの様になります: 表示コンテンツ総数や表示タブ要素Index値を合わせるのが難しい 1. コンテンツ表示部分(UIPageViewControllerで作成された一覧表示部分)で追加考慮すべき事項 UIPageViewControllerDataSource内の処理でコンテンツ部分も 同様に無限循環する挙動を構築 する。 2. タブ型表示部分(UICollectionViewを利用して並べられた要素一覧部分)で追加考慮すべき事項 UICollectionViewFlowLayoutを継承したクラスを適用して Cell要素が中央に停止する 形へ変更する 実際のタブ配置個数は無限循環スクロールに対応するために 実際の個数より多めに配置 しておく UIScrollViewDelegateを活用して無限循環タブ表現部分の挙動を構築する func scrollViewDidScroll(_ scrollView: UIScrollView) X軸方向のOffset値の計算を利用して、無限循環スクロールの挙動を実現する func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) スクロールが停止した際に見えているセルのインデックス値を格納して、真ん中にあるものを取得する ( .visibleCells を利用して表示されているCell要素を全て取得する)

Slide 9

Slide 9 text

UIKit時代に参考にさせて頂いた&利用経験があるOSS例 自前での実装は意外と大変なのでライブラリの活用を検討する場合もあります 対応可能なUI表現やデザイン拡張性等の観点から選定: Parchment: https://github.com/rechsteiner/Parchment PagingKit: https://github.com/kazuhiro4949/PagingKit TabPageViewController: https://github.com/EndouMari/TabPageViewController iOSアプリ画面内でも特徴的な部分で利用される場合も多いので、使いやすさに加えてライブラリが持つ柔軟性も考慮する必要があります。

Slide 10

Slide 10 text

今回は動画掲載内容を参考に自分で再編集をしました サンプル実装コードを一度紐解く際に重要ポイントを洗い出しながら読み進める

Slide 11

Slide 11 text

今回サンプルを試すにあたり参考にしたYouTube動画 動画でライブコーディングをしているサンプル実装内容のインプットを実践する サンプルを紐解く際のポイント: 1. コードを読みながら必要処理をまずは自分の言葉で説明する https://www.patreon.com/kavsoft/posts ※有料会員コンテンツもあるみたいです(僕もまだ未登録です)。 参考: SwiftUI Scrollable Tab Bar - iOS 17 https://www.youtube.com/watch?v=sCK0W39nVEk UIKitで実現したタブ型スクロールを伴う一覧表示UI実装サンプル ① GeometryReaderとScrollViewを活用する X軸方向のOffset値を取得できる様にカスタマイズをする必要がある (GeometryReaderを応用する事で取得可能) 2. 変数名や意図しない重複をリファクタリングして整理する 3. 実践に近しいデータやコンテンツを準備して組み合わせる SwiftUIだけで実現する際に大事な部分はどこか? ② iOS17以降で追加されたScrollView関連のModifierを応用する 他にも@StateやDragGestureの使い方等に関する知識も必要になってくる点

Slide 12

Slide 12 text

類似したUI実装においてSwiftUIで実現する際の方針(1) 考えやすくするためにUIKit利用時と同様に大きく2つの要素に分けてみる Body要素内はタブ要素とコンテンツ要素の様な粒度に分割してみる: ページング処理に 合わせて下線が移動 タブ要素を押下時に 該当コンテンツを表示 // MARK: - Body var body: some View { NavigationStack { VStack(spacing: 0.0) { // 1. Slider式のTab要素を並べたView要素 PremiumPosterTabView() // 2. Slider式のContents要素を並べたView要素 PremiumPosterContentsView() } // Navigation表示に関する設定 .navigationTitle(“Premium Poster") .navigationBarTitleDisplayMode(.inline) } } UIKitの場合はDelegateを応用してView間の処理を接続したが、SwiftUI利用時はどうするか? 別々に定義したView要素間の 処理を最終的には接続する。 👉 `@State`での状態変化を応用 View要素の位置変化に特に注目

Slide 13

Slide 13 text

類似したUI実装においてSwiftUIで実現する際の方針(2) `@State`で定義した変数が「何が変化した時に更新されるか?」に注目する それぞれのView要素内で発生した状態変化を格納する: ページング処理に 合わせて下線が移動 タブ要素を押下時に 該当コンテンツを表示 // MARK: - `@State` Property // 配置対象のTab要素全てを格納する変数 @State private var tabs: [PosterLineupModel] // 現在選択されているTab要素としての変数 @State private var activeTab: PosterLineupModel.Tab // Tab要素をスクロールした時の状態を格納する変数 @State private var tabViewScrollState: PosterLineupModel.Tab? // メインContents要素をスクロールした時の状態を格納する変数 @State private var mainViewScrollState: PosterLineupModel.Tab? // Drag操作をしている最中の変化量を一時的に格納する変数 @State private var progress: CGFloat // 任意のTab要素タップ時からAnimation動作中に表示する連打防止用矩形エリア表示フラグ @State private var showRectangleToPreventRepeatedHits: Bool 定義した変数がいかなるタイ ミングで更新され、どの処理 のために利用されるか?を整 理しながら進めていく。 👉 わかりやすい変数名が良い

Slide 14

Slide 14 text

GeometryReaderを利用して座標位置を取得するExtension 今回紹介するサンプルでは対象要素のCGRect型を返しその値をView処理で利用 GeometryReaderからOffset値を返す様に調整する: extension View { // MARK: - Function @ViewBuilder func getRectangleView(completion: @escaping (CGRect) -> ()) -> some View { // .overlay表示用Modifier内の処理でOffset値を取得できる形にする self.overlay { // GeometryReader内部にはColorを定義してScrollView内に配置する要素には極力影響を及ぼさない様にする GeometryReader { proxy in let rectangle = proxy.frame(in: .scrollView(axis: .horizontal)) // 👉 OffsetPreferenceKey定義とGeometryProxyから取得できる値を紐づける事でこの値変化を監視対象に設定する Color.clear .preference(key: OffsetPreferenceKey.self, value: rectangle) .onPreferenceChange(OffsetPreferenceKey.self, perform: completion) } } } } // MEMO: 配置したTab要素に対して座標位置を取得するためのExtension定義 // GeometryReaderを利用して、親Viewの座標情報等が利用できる点を活用する // 参考: https://blog.personal-factory.com/2019/12/08/how-to-know-coorginate-space-by-geometryreader/ struct OffsetPreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } 透明な背景要素をOffset値の監視対象とする点がポイント .getRectangleView { rect in // … (この値を活用した処理を実行する) … } Tab要素サイズや移動量の割合算出で利用 (用途に合わせてGeometryReaderの基準を変える)

Slide 15

Slide 15 text

コンテンツ表示要素のView構造とScroll連動処理 基本構造はScrollViewから取得した変化量を表示更新に必要な`@State`へ適用 @ViewBuilder private func PremiumPosterContentsView() -> some View { // GeometryReaderを利用してContents表示要素の移動変化量を取得する GeometryReader { proxy in let targetSize = proxy.size // GeometryReaderから取得した値とScrollViewを連動させる方針を取る ScrollView(.horizontal, showsIndicators: false) { // 横一列にタブ要素分だけ対応するコンテンツ要素を並べる LazyHStack(spacing: 0.0) { ForEach(tabs) { tab in // TODO: コンテンツ要素用のView要素を作成する } } .scrollTargetLayout() // 独自に定義した「.getRectangleView」を利用してX軸方向のOffset値を取得する .getRectangleView { rect in // 変化量の割合を格納する変数「progress」へDrag操作最中の変化量を格納する progress = -rect.minX / targetSize.width } } // … ScrollView用のModifier定義 … } } Tab要素の文字列下部に配置した「動 く下線表示」のX軸方向のOffset値に なる点がポイント。 .scrollPosition(id: $mainViewScrollState) .scrollTargetBehavior(.paging) // コンテンツ表示要素におけるX軸方向のOffset値を格納する変数 // 「mainViewScrollState」の変化時に実行される処理 .onChange(of: mainViewScrollState) { oldValue, newValue in if let newValue { // .snappyで弱いバネ運動の様な感じを演出する withAnimation(.snappy) { activeTab = newValue tabViewScrollState = newValue } } } 👉 Tab要素のスクロール位置 & 現在選択中Tab要素を更新 表示コンテンツが メインとなる操作 変数: progresの値は タブ要素関連の位置調 整のために利用する。

Slide 16

Slide 16 text

GeometryReaderから取得する値とScroll関連Modifier iOS17から追加されたScroll関連Modifierとの連携処理に注目する 実装ポイントをまとめる: ① GeometryReaderから取得した値とScrollViewを連動させる方針を取る 👉 ScrollView & LazyHStackの組み合わせなので、X軸方向のOffset値に注目する ② .scrollTargetLayout() & .scrollPosition(id: $mainViewScrollState) に関する解説 👉 scrollTargetLayout(): ScrollView内で特定の位置までスクロールするために必要なModifier 👉 scrollPosition(id: $mainViewScrollState): コンテンツ表示要素におけるX軸方向のOffset値を格納する変数「mainViewScrollState」の位置まで移動 するために必要なModifier 👉 scrollTargetBehavior(.paging): 配置したScrollViewどのように機能するかを決定するためのModifier(今回は.pagingを指定してページ切替の形) https://developer.apple.com/documentation/swiftui/view/scrolltargetlayout(isenabled:) https://developer.apple.com/documentation/swiftui/view/scrollposition(id:anchor:) https://developer.apple.com/documentation/swiftui/scrolltargetbehavior

Slide 17

Slide 17 text

タブ表示要素のView構造とScroll連動処理(1) 様々な要素がかなり複雑に絡み合う構造をとるView要素なので整理がとても大事 @ViewBuilder private func PremiumPosterTabView() -> some View { // MEMO: ZStackを利用して、Tab全体に必要な要素を配置する。 ZStack(alignment: .leading) { // ① Tab要素配置用のScrollView // ② Tab表示エリアに合わせる形で連打防止用にRectangleを重ねる } .scrollPosition(id: $tabViewScrollState, anchor: .center) // Tab要素を並べたScrollViewの上に更に要素を重ねる形を取る .overlay(alignment: .bottom) { // ③ コンテンツ部分のスクロール処理と連動する } .safeAreaPadding(.horizontal, 16.0) } // 👉 .clearを指定すると連続タップ時にTab要素が意図しない位置で停止する // 👉 任意の色を定めてopacityを0未満の小さな値にして対処 if showRectangleToPreventRepeatedHits { Rectangle().fill(.red.opacity(0.001)) .frame(height: 36.0).padding(.horizontal, -16.0) } タブ表示部分を連打し た際においても正しく 動作する様に②の様な 考慮を加える。 ForEachで横1列のButton要素 に並べて表示する。 // 0.00〜0.35秒間は連打防止用の矩形要素を表示した状態にする Task { showRectangleToPreventRepeatedHits = true try await Task.sleep(for: .milliseconds(350)) showRectangleToPreventRepeatedHits = false } // .snappyで弱いバネ運動の様な感じを演出する withAnimation(.snappy) { activeTab = tab.id // 👉 Tab要素のスクロール位置 tabViewScrollState = tab.id // 👉 現在選択されているTab要素 mainViewScrollState = tab.id // 👉 現在選択されているContents要素を更新する } ForEach($tabs) { $tab in … } .getRectangleView { rect in … } .scrollTargetLayout() 動く下線要素をこちらに配置する。

Slide 18

Slide 18 text

タブ表示要素のView構造とScroll連動処理(2) 線形補間の計算式の考え方を応用する事でより自然な表現にするための工夫 この処理があると何が嬉しいのか?: // Tab要素のindex値をArrayに変換する let inputRange = tabs.indices.compactMap { CGFloat($0) } // Tab要素の文字列幅をArrayに変換する let ouputRange = tabs.compactMap { $0.size.width } // Tab要素を並べた時のX軸方向のOffset値の一覧をArrayに変換する let outputPositionRange = tabs.compactMap { $0.minX } // 動く下線要素の幅が変化して、次のタブ要素へ進む(前のタブ要素へ戻る)際の幅を算出する let indicatorWidth = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: ouputRange ) // 動く下線要素の幅が変化して、次のタブ要素へ進む(前のタブ要素へ戻る)際のX軸方向のOffset値を算出する let indicatorPosition = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: outputPositionRange ) 線形補間の計算式と近似誤差: https://manabitimes.jp/math/1422 タブ要素に表示される名前はない様に応じて文字数が変化するの で、切り替わるタイミングで下線の長さを自然に変化させる。 変化量に応じて表示文字列&下線長さが変化する形。 変数: progresの値が計算のベースになる点がポイント

Slide 19

Slide 19 text

タブ表示要素のView構造とScroll連動処理(3) // 座標点の配列を元にした // 線形補間の計算を利用した座標位置 func calculateInterpolate(   inputInterpolateRange: [CGFloat], outputInterpolateRange: [CGFloat] ) -> CGFloat let positionX = self let length = inputInterpolateRange.count - 1 // 最初に与えられた値が最初の入力値より小さい場合は、最初の出力値を返す if positionX <= inputInterpolateRange[0] { return outputInterpolateRange[0] } // 与えられた点の間を近似する処理を実行する // 👉 この値を利用する事でDrag移動時に伴って移動するオブジェクトに対して滑らかな動きを付与する事が可能 for index in 1...length { // 2点間(x1, y1) & (x2, y2)の座標を算出する let x1 = inputInterpolateRange[index - 1] let x2 = inputInterpolateRange[index] let y1 = outputInterpolateRange[index - 1] let y2 = outputInterpolateRange[index] // 算出した座標値を元に線形補間の計算を実行して変化量を算出する // 👉 線形補間の計算式: y1 + ((y2 - y1) / (x2 - x1)) * (positionX - x1) if positionX <= inputInterpolateRange[index] { let positionY = y1 + ((y2 - y1) / (x2 - x1)) * (positionX - x1) return positionY } } // 線形補間の計算で算出できなかった場合は、出力値の最後の値を返す様にする return outputInterpolateRange[length] // 動く下線要素の幅が変化して、次のタブ要素へ進む(前の タブ要素へ戻る)際の幅を算出する let indicatorWidth = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: ouputRange ) // 動く下線要素の幅が変化して、次のタブ要素へ進む(前の タブ要素へ戻る)際のX軸方向のOffset値を算出する let indicatorPosition = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: outputPositionRange ) 補間の公式の考え方をUI実装に応用する 下線部分の表現を綺麗にするための工夫となる点。

Slide 20

Slide 20 text

まとめ iOS17以降ではScrollViewを活用した複雑なUI表現にも耐えうる余地はある iOS18でもSwiftUIのScrollView関連処理において新たな進化があるので、より複雑な実装も可能になる期待があります! 1. GeometryReaderを有効活用する事で表現や配置に必要な値を取得する: iOS17までではSwiftUI純正のScrollViewではOffset値を取得する事ができませんが、GeometryReaderを応用する事で基準位置か らのOffset値を取得する事ができる点を活用する点がポイントになります。 2. ScrollViewに関する便利なModifierを活かした処理となる様に要素を整理する: GeometryReaderから取得した値に加え、ScrollViewに関する便利なModifierもiOS17以降では利用可能なので、この機能を上手に 活かす様な形でView要素の設計や状態変化時の振る舞いを整理すると良さそうに思います。 3. UIKitに比べるとSwiftUIの方がシンプルな形で考えやすくなる場合も多い: 複雑なUI実装をSwiftUIで実現する際は、状態管理の難しさや利用可能な機能のバージョン差異をはじめ注意が必要な部分はあり ますが、位置情報取得処理を活用する事によって、シンプルな形で実現できる場合もありそうだと感じました。

Slide 21

Slide 21 text

(余談)個人的に気になったトピックのまとめノート 不定期ではありますがSwiftUIを利用した実装に関する情報をまとめています 個人的に気になったUI表現や実装関連TIPSは積極的に手元で試したり、知見をまとめていこうと思います! https://x.com/fumiyasac/status/1808177826237239418 https://x.com/fumiyasac/status/1810009745065132152

Slide 22

Slide 22 text

Thank you for listening ! 今回紹介したSwiftUI製のサンプルコードはこちらでも公開しています。 Gist: https://gist.github.com/fumiyasac/7429e33a244c980de494cb8c5a616f48