SwiftUIで無限カルーセルを実装する
この記事はQiita Advent Calendar iOSの21日目の記事です。
こんにちは、iOSエンジニアの高橋です。
以前SwiftUIでカルーセルを実装しましたが、今回はループができるように改造していきたいと思います。
前回の記事はこちら
今回実装するもの
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. 実装プラン
前回では以下のようなカルーセルを実装できました。
今回このカルーセルをループできるように、以下のようなプランで実装をしました。
1. 図1のように配列を加工する
2. index = 1 or index = 元配列の個数 + 2(図1の場合は、5となる)のタイミングでoffsetを更新する
それぞれ詳しくみていきたいと思います。
図1.
1. 配列を加工する
今回、ループしているように見せるために配列を以下のように加工して使用します。
先頭・末尾2要素分を追加し、初期表示でindex=2とすることで以下のように表示することができます。
このViewを右へOffset移動させると以下のような画面表示になります。
このように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...とループを実装することができます。
2-2. index = 元配列の個数 + 2の場合
index = 元配列の個数 + 2(図ではindex = 5)の場合は、以下のように
index = 2(初期表示位置)にoffsetを移動することで、1→2→3→1...とループを実装することができます。
このようにindex = 1 or index = 元配列の個数 + 2(図1の場合は、5となる)のタイミングでoffsetを移動することでループを実現させることができます。
ただこのままでは、offset更新時に以下のようなアニメーションがある状態になってしまいます。
こちらを解決するためには、dragGestureによるoffset移動アニメーション後にループのためのoffset移動をアニメーションなしで更新する処理が必要になります。続いてこちらを実装していきます。
3. offset移動アニメーション
ここでは、dragGestureによるoffset移動アニメーション(通常のスクロール)後にループするためのoffset移動(アニメーションなし)を実装してきます。UIKitのようにアニメーション後にコールバックするようなものがSwiftUIには、おそらくないためアニメーション時間に注目して実装してみました。例えば、dragGestureによるoffset移動アニメーション時間を0.2sとした時以下のようになります。
このように実装できれば違和感なく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. 完成
ここまでの対応をまとめると以下のようになります。
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して実装した方が良さそうだと感じています。またアップデート出来次第、報告ができればと思います。
お知らせ
スペースマーケットでは現在アプリエンジニア採用中です。
アプリで社会課題を解決しながら、あたりまえの世界を作っていきたい方がいらっしゃれば、ぜひお話しさせていただきたいです!
この記事が参加している募集
この記事が気に入ったらサポートをしてみませんか?