見出し画像

SwiftUIで無限カルーセルを実装する

この記事はQiita Advent Calendar iOSの21日目の記事です。
こんにちは、iOSエンジニアの高橋です。
以前SwiftUIでカルーセルを実装しましたが、今回はループができるように改造していきたいと思います。

前回の記事はこちら

今回実装するもの

画像2

1. 画面の中央にViewが止まる
2. 両サイドに前後のViewが見える
3. ループする(今回こちらを実装していきます)

完成コード

ViewModel

import SwiftUI
import Combine

class ContentViewModel: ObservableObject {
   // 各itemの幅
   private let ITEM_PADDING: CGFloat = 20
   // offset移動アニメーション時間
   private let OFFSET_X_ANIMATION_TIME: Double = 0.2
   private var cancellableSet: Set<AnyCancellable> = []
   
   // 加工前の配列
   private var examples = ["1", "2", "3", "4"]
   // 無限カルーセル用に加工した配列
   @Published var infinityArray: [String] = []
   // 初期位置は2に設定する
   @Published var currentIndex = 2
   // アニメーションの有無を操作
   @Published private var isOffsetAnimation: Bool = false
   // アニメーション
   @Published var dragAnimation: Animation? = nil
   
   init() {
       self.infinityArray = createInfinityArray(examples)
       
       $currentIndex
           .receive(on: RunLoop.main)
           .sink { index in
               // 2要素未満の場合は、無限スクロールにしないため処理は必要なし
               if self.examples.count < 2 {
                   return
               }
               
               // 無限スクロールを実現させるため、オフセット移動アニメーション後(0.2s後)にcurrentIndexをリセットする
               DispatchQueue.main.asyncAfter(deadline: .now() + self.OFFSET_X_ANIMATION_TIME) {
                   if index <= 1 {
                       self.isOffsetAnimation = false
                       self.currentIndex = 1 + self.examples.count
                   } else if index >= 2 + self.examples.count {
                       self.isOffsetAnimation = false
                       self.currentIndex = 2
                   }
               }
           }
           .store(in: &cancellableSet)
       
       $isOffsetAnimation
           .receive(on: RunLoop.main)
           .map { isAnimation in
               return isAnimation ? .linear(duration: self.OFFSET_X_ANIMATION_TIME) : .none
           }
           .assign(to: \.dragAnimation, on: self)
           .store(in: &cancellableSet)
       
   }
   
   /// 擬似無限スクロール用の配列を生成:ex) [1,2,3]→[2,3,1,2,3,1,2]
   private func createInfinityArray(_ targetArray: [String]) -> [String] {
       if targetArray.count > 1 {
           var result: [String] = []
           // 最後の2要素
           result += targetArray.suffix(2)
           // 本来の配列
           result += targetArray
           // 最初の2要素
           result += targetArray.prefix(2).map { $0 }

           return result
       } else {
           return targetArray
       }
   }
}

/// 各種メソッド
extension ContentViewModel {
   /// itemPadding
   func carouselItemPadding() -> CGFloat {
       return ITEM_PADDING
   }
   
   /// カルーセル各要素のWidth
   func carouselItemWidth(bodyView: GeometryProxy) -> CGFloat {
       return bodyView.size.width * 0.8
   }
   
   /// itemを中央に配置するためにカルーセルのleading paddingを返す
   func carouselLeadingPadding(index: Int, bodyView: GeometryProxy) -> CGFloat {
       return index == 0 ? bodyView.size.width * 0.1 : 0
   }
   
   /// カルーセルのOffsetのX値を返す
   func carouselOffsetX(bodyView: GeometryProxy) -> CGFloat {
       return -CGFloat(self.currentIndex) * (bodyView.size.width * 0.8 + self.ITEM_PADDING)
   }
   
   /// ドラッグ操作
   func onChangedDragGesture() {
       // ドラッグ時にはアニメーション有効
       if self.isOffsetAnimation == false {
           self.isOffsetAnimation = true
       }
   }
   
   /// ドラッグ操作によるcurrentIndexの操作
   func updateCurrentIndex(dragGestureValue: _ChangedGesture<GestureStateGesture<DragGesture, CGFloat>>.Value, bodyView: GeometryProxy) {
       var newIndex = currentIndex
       // ドラッグ幅からページングを判定
       if abs(dragGestureValue.translation.width) > bodyView.size.width * 0.3 {
           newIndex = dragGestureValue.translation.width > 0 ? self.currentIndex - 1 : self.currentIndex + 1
       }
       
       // 最小ページ、最大ページを超えないようチェック
       if newIndex < 0 {
           newIndex = 0
       } else if newIndex > (self.infinityArray.count - 1) {
           newIndex = self.infinityArray.count - 1
       }
       
       self.isOffsetAnimation = true
       self.currentIndex = newIndex
   }
}

View

import SwiftUI

struct ContentView: View {
   
   @ObservedObject private var viewModel = ContentViewModel()
   @GestureState private var dragOffset: CGFloat = 0
   
   var body: some View {
       GeometryReader { bodyView in
           LazyHStack(spacing: viewModel.carouselItemPadding()) {
               ForEach(viewModel.infinityArray.indices, id: \.self) { index in
                   Text(viewModel.infinityArray[index])
                       .foregroundColor(Color.white)
                       .font(.system(size: 50, weight: .bold))
                       .frame(width: viewModel.carouselItemWidth(bodyView: bodyView), height: 300)
                       .background(Color.gray)
                       .padding(.leading, viewModel.carouselLeadingPadding(index: index, bodyView: bodyView))
               }
           }
           .offset(x: dragOffset)
           .offset(x: viewModel.carouselOffsetX(bodyView: bodyView))
           .animation(viewModel.dragAnimation)
           .gesture(
               DragGesture()
                   .updating($dragOffset, body: { (value, state, _) in
                       state = value.translation.width
                   })
                   .onChanged({ value in
                       viewModel.onChangedDragGesture()
                   })
                   .onEnded({ value in
                       viewModel.updateCurrentIndex(dragGestureValue: value, bodyView: bodyView)
                   })
           )
       }
   }
}

0. 実装プラン

前回では以下のようなカルーセルを実装できました。

画像11

今回このカルーセルをループできるように、以下のようなプランで実装をしました。

1. 図1のように配列を加工する
2. index = 1 or index = 
元配列の個数 + 2(図1の場合は、5となる)のタイミングでoffsetを更新する

それぞれ詳しくみていきたいと思います。

図1. 

スクリーンショット 2021-12-19 15.22.34

1. 配列を加工する

今回、ループしているように見せるために配列を以下のように加工して使用します。

スクリーンショット 2021-12-19 15.22.34

先頭・末尾2要素分を追加し、初期表示でindex=2とすることで以下のように表示することができます。

スクリーンショット 2021-12-19 15.51.39

このViewを右へOffset移動させると以下のような画面表示になります。

スクリーンショット 2021-12-19 15.57.47

このように1(先頭)→3(末尾)に移動でき、本来末尾に表示される要素がループしているような配列を作成することができます。この配列に加工するコードでは以下のようになります。

/// 擬似無限スクロール用の配列を生成:ex) [1,2,3]→[2,3,1,2,3,1,2]
func createInfinityArray(_ targetArray: [String]) -> [String] {
   if targetArray.count > 1 {
       var result: [String] = []
       // 末尾の2要素
       result += targetArray.suffix(2)
       // 本来の配列
       result += targetArray
       // 先頭の2要素
       result += targetArray.prefix(2).map { $0 }

       return result
   } else {
       return targetArray
   }
}

今回は、両サイドに前後のViewが見えるように実装しているので2要素分必要でしたが、見せる必要がない場合は先頭・末尾1要素を追加するだけで問題ないと思います。
ただ、このままではindex = 0に移動できてしまい無限カルーセルとはならないので、続いてoffsetを更新する処理を実装していきます。

2. offsetの更新

ここではループしているように見せるため、特定のindexの要素を表示したタイミングでoffsetを移動する処理を実装していきます。特定のindexとは、index = 1 or index = 元配列の個数 + 2(図1の場合は、5となる)になります。実際にどのようにoffsetを更新するのかを見ていきたいと思います。

2-1. index = 1の場合

index = 1の場合は、以下のようにindex = 4(元配列の個数 + 1)にoffsetを移動することで、3→2→1→3...とループを実装することができます。

スクリーンショット 2021-12-19 16.59.42

2-2. index = 元配列の個数 + 2の場合

index = 元配列の個数 + 2(図ではindex = 5)の場合は、以下のように
index = 2(初期表示位置)にoffsetを移動することで、1→2→3→1...とループを実装することができます。

スクリーンショット 2021-12-19 17.09.34

このようにindex = 1 or index = 元配列の個数 + 2(図1の場合は、5となる)のタイミングでoffsetを移動することでループを実現させることができます。
ただこのままでは、offset更新時に以下のようなアニメーションがある状態になってしまいます。

画像11

こちらを解決するためには、dragGestureによるoffset移動アニメーション後にループのためのoffset移動をアニメーションなしで更新する処理が必要になります。続いてこちらを実装していきます。

3. offset移動アニメーション

ここでは、dragGestureによるoffset移動アニメーション(通常のスクロール)後にループするためのoffset移動(アニメーションなし)を実装してきます。UIKitのようにアニメーション後にコールバックするようなものがSwiftUIには、おそらくないためアニメーション時間に注目して実装してみました。例えば、dragGestureによるoffset移動アニメーション時間を0.2sとした時以下のようになります。

スクリーンショット 2021-12-19 18.02.17

このように実装できれば違和感なくoffsetの更新ができると思います。コードでは以下のようになります。

ViewModel

import SwiftUI
import Combine

class ContentViewModel: ObservableObject {
   // 各itemの幅
   private let ITEM_PADDING: CGFloat = 20
   // offset移動アニメーション時間
   private let OFFSET_X_ANIMATION_TIME: Double = 0.2
   private var cancellableSet: Set<AnyCancellable> = []
   
   // 加工前の配列
   private var examples = ["1", "2", "3", "4"]
   // 無限カルーセル用に加工した配列
   @Published var infinityArray: [String] = []
   // 初期位置は2に設定する
   @Published var currentIndex = 2
   // アニメーションの有無を操作
   @Published private var isOffsetAnimation: Bool = false
   // アニメーション
   @Published var dragAnimation: Animation? = nil
   
   init() {
       self.infinityArray = createInfinityArray(examples)
       
       // currentIndexの値に応じてoffset更新を行う
       $currentIndex
           .receive(on: RunLoop.main)
           .sink { index in
               // 2要素未満の場合は、無限スクロールにしないため処理は必要なし
               if self.examples.count < 2 {
                   return
               }
               
               // 無限スクロールを実現させるため、オフセット移動アニメーション後(0.2s後)にcurrentIndexをリセットする
               DispatchQueue.main.asyncAfter(deadline: .now() + self.OFFSET_X_ANIMATION_TIME) {
                   if index <= 1 {
                       self.isOffsetAnimation = false
                       self.currentIndex = 1 + self.examples.count
                   } else if index >= 2 + self.examples.count {
                       self.isOffsetAnimation = false
                       self.currentIndex = 2
                   }
               }
           }
           .store(in: &cancellableSet)
       
       // isOffsetAnimationの値に応じて対応するアニメーションを行う
       $isOffsetAnimation
           .receive(on: RunLoop.main)
           .map { isAnimation in
               return isAnimation ? .linear(duration: self.OFFSET_X_ANIMATION_TIME) : .none
           }
           .assign(to: \.dragAnimation, on: self)
           .store(in: &cancellableSet)
       
   }
}

View

LazyHStack(spacing: viewModel.carouselItemPadding()) {
    ...
}
。animation(viewModel.dragAnimation) // アニメーションを操作

また、今回はアニメーション時間を指定して待機するように実装していますが、AnimatableModifierを使ってoffset移動後にコールバックするようなカスタムmodifierを作成する方法もあるかと思います。例えば以下のようなコードになります。

import SwiftUI

struct OffsetXEffectModifier: AnimatableModifier {
   var myOffsetX: CGFloat
   var targetOffsetX: CGFloat
   var onCompletion: (() -> Void)?

   init(targetOffsetX: CGFloat, onCompletion: (() -> Void)? = nil) {
       self.myOffsetX = targetOffsetX
       self.targetOffsetX = targetOffsetX
       self.onCompletion = onCompletion
   }

   var animatableData: CGFloat {
       get { targetOffsetX }
       set {
           targetOffsetX = newValue
           checkIfFinished()
       }
   }

   func checkIfFinished() {
       if let onCompletion = onCompletion, myOffsetX == targetOffsetX {
           DispatchQueue.main.async {
               onCompletion()
           }
       }
   }

   func body(content: Content) -> some View {
       content.offset(x: targetOffsetX)
   }
}

しかし、AnimatableModifierはDeprecatedになっているため使用を避けた方が良いと思います。
とはいえ、現在のアニメーション時間を指定する方法は良いとは思っていないので別の方法を現在模索中になります🙇‍♂️

4. 完成

画像2

ここまでの対応をまとめると以下のようになります。

ViewModel

import SwiftUI
import Combine

class ContentViewModel: ObservableObject {
   // 各itemの幅
   private let ITEM_PADDING: CGFloat = 20
   // offset移動アニメーション時間
   private let OFFSET_X_ANIMATION_TIME: Double = 0.2
   private var cancellableSet: Set<AnyCancellable> = []
   
   // 加工前の配列
   private var examples = ["1", "2", "3", "4"]
   // 無限カルーセル用に加工した配列
   @Published var infinityArray: [String] = []
   // 初期位置は2に設定する
   @Published var currentIndex = 2
   // アニメーションの有無を操作
   @Published private var isOffsetAnimation: Bool = false
   // アニメーション
   @Published var dragAnimation: Animation? = nil
   
   init() {
       self.infinityArray = createInfinityArray(examples)
       
       $currentIndex
           .receive(on: RunLoop.main)
           .sink { index in
               // 2要素未満の場合は、無限スクロールにしないため処理は必要なし
               if self.examples.count < 2 {
                   return
               }
               
               // 無限スクロールを実現させるため、オフセット移動アニメーション後(0.2s後)にcurrentIndexをリセットする
               DispatchQueue.main.asyncAfter(deadline: .now() + self.OFFSET_X_ANIMATION_TIME) {
                   if index <= 1 {
                       self.isOffsetAnimation = false
                       self.currentIndex = 1 + self.examples.count
                   } else if index >= 2 + self.examples.count {
                       self.isOffsetAnimation = false
                       self.currentIndex = 2
                   }
               }
           }
           .store(in: &cancellableSet)
       
       $isOffsetAnimation
           .receive(on: RunLoop.main)
           .map { isAnimation in
               return isAnimation ? .linear(duration: self.OFFSET_X_ANIMATION_TIME) : .none
           }
           .assign(to: \.dragAnimation, on: self)
           .store(in: &cancellableSet)
       
   }
   
   /// 擬似無限スクロール用の配列を生成:ex) [1,2,3]→[2,3,1,2,3,1,2]
   private func createInfinityArray(_ targetArray: [String]) -> [String] {
       if targetArray.count > 1 {
           var result: [String] = []
           // 最後の2要素
           result += targetArray.suffix(2)
           // 本来の配列
           result += targetArray
           // 最初の2要素
           result += targetArray.prefix(2).map { $0 }

           return result
       } else {
           return targetArray
       }
   }
}

/// 各種メソッド
extension ContentViewModel {
   /// itemPadding
   func carouselItemPadding() -> CGFloat {
       return ITEM_PADDING
   }
   
   /// カルーセル各要素のWidth
   func carouselItemWidth(bodyView: GeometryProxy) -> CGFloat {
       return bodyView.size.width * 0.8
   }
   
   /// itemを中央に配置するためにカルーセルのleading paddingを返す
   func carouselLeadingPadding(index: Int, bodyView: GeometryProxy) -> CGFloat {
       return index == 0 ? bodyView.size.width * 0.1 : 0
   }
   
   /// カルーセルのOffsetのX値を返す
   func carouselOffsetX(bodyView: GeometryProxy) -> CGFloat {
       return -CGFloat(self.currentIndex) * (bodyView.size.width * 0.8 + self.ITEM_PADDING)
   }
   
   /// ドラッグ操作
   func onChangedDragGesture() {
       // ドラッグ時にはアニメーション有効
       if self.isOffsetAnimation == false {
           self.isOffsetAnimation = true
       }
   }
   
   /// ドラッグ操作によるcurrentIndexの操作
   func updateCurrentIndex(dragGestureValue: _ChangedGesture<GestureStateGesture<DragGesture, CGFloat>>.Value, bodyView: GeometryProxy) {
       var newIndex = currentIndex
       // ドラッグ幅からページングを判定
       if abs(dragGestureValue.translation.width) > bodyView.size.width * 0.3 {
           newIndex = dragGestureValue.translation.width > 0 ? self.currentIndex - 1 : self.currentIndex + 1
       }
       
       // 最小ページ、最大ページを超えないようチェック
       if newIndex < 0 {
           newIndex = 0
       } else if newIndex > (self.infinityArray.count - 1) {
           newIndex = self.infinityArray.count - 1
       }
       
       self.isOffsetAnimation = true
       self.currentIndex = newIndex
   }
}

View

import SwiftUI

struct ContentView: View {
   
   @ObservedObject private var viewModel = ContentViewModel()
   @GestureState private var dragOffset: CGFloat = 0
   
   var body: some View {
       GeometryReader { bodyView in
           LazyHStack(spacing: viewModel.carouselItemPadding()) {
               ForEach(viewModel.infinityArray.indices, id: \.self) { index in
                   Text(viewModel.infinityArray[index])
                       .foregroundColor(Color.white)
                       .font(.system(size: 50, weight: .bold))
                       .frame(width: viewModel.carouselItemWidth(bodyView: bodyView), height: 300)
                       .background(Color.gray)
                       .padding(.leading, viewModel.carouselLeadingPadding(index: index, bodyView: bodyView))
               }
           }
           .offset(x: dragOffset)
           .offset(x: viewModel.carouselOffsetX(bodyView: bodyView))
           .animation(viewModel.dragAnimation)
           .gesture(
               DragGesture()
                   .updating($dragOffset, body: { (value, state, _) in
                       state = value.translation.width
                   })
                   .onChanged({ value in
                       viewModel.onChangedDragGesture()
                   })
                   .onEnded({ value in
                       viewModel.updateCurrentIndex(dragGestureValue: value, bodyView: bodyView)
                   })
           )
       }
   }
}​

5. まとめ

実装をしてみたものの、まだまだ改良できる箇所があると思うので学習しながらより良いコードにしていきたいと思いました。そして、やはりUIKitでできていたことができないのが辛い点ではあるので潔くUIKitをWrapして実装した方が良さそうだと感じています。またアップデート出来次第、報告ができればと思います。

お知らせ

スペースマーケットでは現在アプリエンジニア採用中です。
アプリで社会課題を解決しながら、あたりまえの世界を作っていきたい方がいらっしゃれば、ぜひお話しさせていただきたいです!


この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?