Slide 1

Slide 1 text

SwiftUIで座標位置を取り扱う場合の 細かなポイントを探ろう Fumiya Sakai Mobile勉強会 ウォンテッドリー × チームラボ × Sansan #17 @ Wantedly様 2024/11/27

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

Slide 5

Slide 5 text

技術書同人誌博覧会スタッフ&デザイン協力もしてます お申し込みフォーム: https://gishohaku.dev/gishohaku11/circle-entry 2025年1月25日開催の第11回技術書同人誌博覧会のサークル受付開始しました! 皆様のご応募お待ちしております🙏 🍀 #9 一般参加募集チラシ 🍀 #10 次回案内チラシ 🍀 #11 次回案内チラシ 🍀 技書博 mini OSC案内

Slide 6

Slide 6 text

今回はGeometryReaderに関するお話しをします SwiftUIを利用した画面構築ではお世話になる機会は多そうだけども… これまでの過去事例からいくつかピックアップして解説できればと思います。 1. 簡単な実装 vs 複雑な実装における難易度とギャップ: 簡単な実装であれば、さほど苦労はしない事も多いかもしれません。しかしながら、Animation表現等にも関連がある様な複雑な 実装を考える際では、難易度が一気に上がってしまう事は多いと思います。 2. 座標基準をどこに設定するか?という問題: GeometryReaderでの表現を考える際に注意が必要なのは「座標基準をどこに設定するか?」という点にあると考えています。座 標位置の基準を誤ってしまうと、位置合わせやデバッグが困難になる場合があります。 3. 意外といい感じの事例が思い浮かばない?という問題: 私も最初GeometryReaderに関するサンプル実装を探していた際に、自分に合った難易度をものを探し当てる・コードの概要とポ イントを読み解く事に苦労しました。過去に取り組んだ打開策についても今回お伝えします。

Slide 7

Slide 7 text

GeometryReaderを利用した表現における過去事例 DroidKaigi2023/2024でContributionを実践した際にも応用した表現をしました 1. Androidアプリである様な近しい表現をするためのアプローチ: 2023年: CoodinatorLayout + TabLayout を組み合わせた様な形 2024年: GeometryReaderからの値を位置合わせで利用する 2. 特徴的なAnimation表現を位置計算で実現する:

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 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 11

Slide 11 text

スクロールに連動した日付選択タブの動きを作る(4) 配置要素の間隔等も考慮した位置関係を算出する際は見通しが良い状態にする 特に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 12

Slide 12 text

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

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15 text

スクロールに連動した日付選択タブの動きを作る(8) 画面全体構造における基本方針は「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 16

Slide 16 text

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

Slide 17 text

お気に入り追加時のハートマークAnimation対応(1) Androidで実現する動きが綺麗でしたので、SwiftUIでもできるか試してみた 既存のView構造でどう構築するか?を考える: 直線的な動きならば強引にできるかも: 1. ScrollViewを基準とした位置取得 2. Animation終端位置をGeometryReaderで計算する 👉 画面を基準とした位置はGeometryReaderから算出 Animation概要 👉 DragGestureの処理で代用できないか?を考えてみる ① Button要素では現在の位置が取得できなかった 課題となった部分 ❤ ❤ 理想の動き 現実の動き ❤ ❤ 弧を描く 直線的 👉 お気に入りボタンで追加処理時に位置情報を渡す ② お気に入りに追加した時の考慮ポイント 👉 おおもとの画面をZStackにしてAnimation用View要素と分離する

Slide 18

Slide 18 text

お気に入り追加時のハートマークAnimation対応(2) タイムテーブル表示要素からタップされた位置を取得するための処理抜粋 // [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)

Slide 19

Slide 19 text

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() } お気に入り追加時のハートマークAnimation対応(3) タイムテーブル一覧画面の構造は一覧表示と動きを作るための要素を分割する 👉 タイムテーブル一覧表示は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を更新

Slide 20

Slide 20 text

お気に入り追加時のハートマークAnimation対応(4) @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 } } }

Slide 21

Slide 21 text

弧を描く様なアニメーションにするためには? この点はまだまだ改善できるかも?と思い探究中です Pathアニメーションを利用する事ができれば、かなり近い表現ができるかもしれない… Pathアニメーションないしはkeyframeアニメーションを検討中 今後の展望 現在GeometryReaderでの計算式の部分を改善できれば… https://www.youtube.com/watch?v=RyuwMSUemzc NetflixアプリのOnboarding画面で利用している様な表現 https://www.youtube.com/watch?v=Yz82uI-X98g - GeometryReaderの計算がとても厄介 - Animation表現のクオリティは妥協せざるを得なかった 🍀 現在感じているこれはちょっとな…と思うポイント - 位置算出の基準を間違えてしまうと失敗しやすい - 表現を含めたデバッグがとても面倒 - 弧を描く様な感じは現状作成する方が難しい

Slide 22

Slide 22 text

補足1. 上手に応用できると表現の幅が一気に広がる 🍎 位置に応じてTab要素が折り畳まれる表現 位置に応じて Tab状態が変化 ① Tab要素内の矩形移動はGeometryReaderで作成 🔖 ScrollViewで利用可能なModifierの性質を利用する LazyVStackのSection要素 ② 一覧表示用のScrollViewではOffset値を取得 Navigation位置まで到達したらTab状態を変更 🐤 Scroll変化量と連動した複雑なTab要素例: Contents要素のスクロール変化量と連動させる 🍎 ScrollView要素で作るStickyHeader表現 条件 上端が固定されて サムネイルが拡大 ① GeometryReaderから取得した座標値を利用する ② 一覧表示用のScrollViewではOffset値を取得 proxy.frame(in: .global).minY <= 0 全体View要素配置や`@State`の条件を工夫する 🔖 true: proxy.sizeの値を適用する事で実現する ※条件に合致しない場合は通常通りのスクロール処理 🐤 Header要素と戻るボタン表示等を連動する: 特定位置に到達したら要素表示する等の工夫が可能 🍎 Sectionに応じてTab位置が変化 $VSSZ 1J[[B 4BOEXJUDI 🍛 ΧϨʔಛू … List要素等でSection毎に一覧表示 $VSSZ 1J[[B 4BOEXJUDI 🍕 ϐοπΝಛू SectionHeader要素 次のSectionへ到達 スクロール実行 🔖 ScrollViewReaderを活用した処理と合わせる Contents要素のスクロール時にTab要素を移動 & Tab要素押下時は特定要素Section要素まで移動

Slide 23

Slide 23 text

補足2. GeometryReaderで取得した値と表現の設計 特に既存のUIに調整対応を加える部分については要点をまとめる様にしました 2023年のContribution当時の設計資料: https://twitter.com/fumiyasac/status/1695835586719044047 https://twitter.com/fumiyasac/status/1693501752811917546 2024年のiOSDCのパンフレット原稿資料: https://github.com/fumiyasac/iosdc2024_pamphlet_manuscript_vol2

Slide 24

Slide 24 text

補足3. UI表現を設計する際にまとめたノート 特に関心が高いのはGeometryReader & MatchedGeometryEffectの組み合わせ まずはコードから読み解く事ができる情報や実装に関するイメージを自分の言葉でまとめてみる

Slide 25

Slide 25 text

補足4. UI表現を設計する際にまとめたノート 特に関心が高いのはGeometryReader & MatchedGeometryEffectの組み合わせ まずはコードから読み解く事ができる情報や実装に関するイメージを自分の言葉でまとめてみる

Slide 26

Slide 26 text

まとめ 座標位置計算の基準を押さえる&多くの事例に触れ合う事が大切 複雑なレイアウト構築時ではGeometryReaderの使い方がポイントになる事も多いと思います。 1. GeometryReaderから取得した値を活用する事で様々な表現に応用可能: 今回は過去のDroidKaigiで取り組んだUI実装を題材にしてGeometryReaderを利用した計算事例を紹介しました。SwiftUI要素だけ では実現が難しいUI実装や表現であっても、取得した値をうまく活用する事で実現可能となる場合も多くあります。 2. GeometryReaderの基準を押さえて利用する値を整理する: GeometryReaderから取得した値を活用する場合は「どの位置を始点とするか?」という点に注意する。特にScroll処理と連動し て別要素に影響がある様なUI実装の場合は注意が必要になります。(ScrollView/ScrollViewReaderとの組み合わせ) 3. サンプル実装に触れて重要なポイントとイメージをセットでまとめる: コードを読むだけではUIの振る舞いや伴って変化する要素に対するイメージが掴みにくい場合も多い点に難しさがあると考えて います。仕様は勿論ですが、実装に関連する点を具体的なイメージをコードと併せてまとめると良いかと思います。

Slide 27

Slide 27 text

Thank you for listening !