🐙
SwiftUIでTinderっぽいスワイプ操作のCard View
完成形
Usage
struct ExamleSwipableCardView: View {
struct Card: Identifiable {
let id: Int
let text: String
}
@State private var cardList: [Card] = (0..<10).map({ Card(id: $0, text: "Number \($0)") })
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
StackSwipableCardView(self.cardList.prefix(3),
cardContent: { (card: Card) in
Text(card.text)
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
},
onEndedMove: { (card: Card, translation: CGSize) in
switch translation.width {
case let w where w < -0.3 * proxy.size.width:
return .throwLeft
case let w where w > 0.3 * proxy.size.width:
return .throwRight
default:
return .none
}
},
onThrowAway: { (card: Card) in
self.cardList.removeAll(where: { $0.id == card.id })
})
.padding(.bottom, 16)
}
}
}
- データソースからviewの生成
- 指を離した時に移動量から左/右に飛ばす、元に戻すかを決定
- 左右に飛ばし終わった時にコールバック
- stackするviewの数は動的に変更可能
Gist
実装
順番に実装して行きます
- スワイプする対象のカード
- DragGestureで移動するカード
- DragGestureで回転しながら横移動するカード
- Dragして指を離すと飛んでいくカード
- ずらしながらzstack
- Dragして指を離すと、次のカードを表示する
スワイプする対象のカード
struct SingleCardView: View {
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
Text("Number 0")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
}
}
}
- シンプルなtextが一つのviewです
DragGestureで移動するカード
struct DragableCardView: View {
@State private var translation: CGSize = .zero
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
Text("Number 0")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.offset(self.translation)
.gesture(
DragGesture()
.onChanged({ self.translation = $0.translation })
.onEnded({ _ in self.translation = .zero })
)
}
}
}
- カードにDragGestureを登録し、translationをoffsetに設定します
DragGestureで回転しながら横移動するカード
struct DragableCardView: View {
@State private var translation: CGSize = .zero
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
Text("Number 0")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.offset(CGSize(width: self.translation.width, height: 0))
.rotationEffect(.degrees(Double(self.translation.width / 300) * 25), anchor: .bottom)
.gesture(
DragGesture()
.onChanged({ self.translation = $0.translation })
.onEnded({ _ in self.translation = .zero })
)
}
}
}
- translationのoffsetのheightを0で固定します
- rotationEffectを追加しました
Dragして指を離すと飛んでいくカード
struct DragableCardView: View {
@State private var translation: CGSize = .zero
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
Text("Number 0")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.offset(CGSize(width: self.translation.width, height: 0))
.rotationEffect(.degrees(Double(self.translation.width / 300) * 25), anchor: .bottom)
.gesture(
DragGesture()
.onChanged({ self.translation = $0.translation })
.onEnded({ _ in self.translation = CGSize(width: 600, height: 0) })
)
}
}
}
- DragGestureのonEndedで遠くへ飛ばします
ずらしながらzstack
struct StackedCardView: View {
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
ZStack {
ForEach([2, 1, 0], id: \.self) { (i: Int) in
Text("Number \(i)")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.scaleEffect(pow(0.9, CGFloat(i))) // 少し小さくする
.offset(CGSize(width: 0, height: CGFloat(i) * 50)) // ずらす
}
}
}
}
}
- 表示させる数字に応じて、scaleEffeftとoffsetを決定します
Dragして指を離すと、次のカードを表示する
struct StackedCardView: View {
@State private var numbers = [2, 1, 0]
@State private var translation: CGSize = .zero
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
ZStack {
ForEach(self.numbers, id: \.self) { (i: Int) in
Text("Number \(i)")
.font(.title)
.frame(width: proxy.size.width - 16 * 2, height: proxy.size.height - 16 * 3, alignment: .center)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.offset(self.numbers.last == i ? self.translation : .zero)
.gesture(
DragGesture()
.onChanged({ self.translation = $0.translation })
.onEnded({ _ in
self.numbers.removeLast()
self.translation = .zero
})
)
}
}
}
}
}
- 表示するソースの配列から削除します
generic使って汎用的なviewにする
viewの表示に必要な設定や値の計算式を抜き出しました
Configuration
public struct StackSwipableCardConfiguration {
/// stackされたviewの小さくなる倍率
public let calcScale: () -> CGFloat
/// stackされたviewがずれる割合
public let calcOffset: () -> CGSize
/// 移動量に応じた回転量
public let calcRotation: (_ translation: CGSize) -> Angle
/// 左右に飛んでいった先の位置
public let threwLeft: (point: CGSize, duration: Double)
public let threwRight: (point: CGSize, duration: Double)
}
Swipeで消えるカード
SwipableCardView
fileprivate struct SwipableCardView<Source, CardContent: View>: View {
internal let source: Source
internal let configuration: StackSwipableCardConfiguration
internal let cardContent: (Source) -> CardContent
internal let onEndedMove: (Source, CGSize) -> CardViewEndedMoveAction
internal let onThrowAway: (Source) -> Void
@State private var translation: CGSize = .zero
public var body: some View {
self.cardContent(source)
.rotationEffect(configuration.calcRotation(translation), anchor: .bottom)
.offset(x: translation.width, y: 0)
.scaleEffect(configuration.calcScale())
.offset(configuration.calcOffset())
.gesture(gesture)
}
private var gesture: some Gesture {
return DragGesture()
.onChanged({ value in
self.translation = value.translation
})
.onEnded({ value in
let action = self.onEndedMove(self.source, value.translation)
switch action {
case .throwLeft:
withAnimation(.easeInOut(duration: self.configuration.threwLeft.duration), {
self.translation = self.configuration.threwLeft.point
})
DispatchQueue.main.asyncAfter(deadline: .now() + self.configuration.threwLeft.duration) {
self.onThrowAway(self.source)
}
case .throwRight:
withAnimation(.easeInOut(duration: self.configuration.threwRight.duration), {
self.translation = self.configuration.threwRight.point
})
DispatchQueue.main.asyncAfter(deadline: .now() + self.configuration.threwRight.duration) {
self.onThrowAway(self.source)
}
case .none:
self.translation = .zero
}
})
}
}
usage
struct SwipableCardView_Example: View {
@State var color: Color = .clear
var body: some View {
GeometryReader { (proxy: GeometryProxy) in
SwipableCardView(source: 1,
configuration: .makeDefault(sourcesSize: 1, index: 1, proxy: proxy),
cardContent: { i in Text("number \(i)").padding(32).background(Color.green) },
onEndedMove: { _,_ in .throwRight },
onThrowAway: { _ in self.color = .red })
.frame(width: proxy.size.width - 32, height: 700, alignment: .center)
.padding(16)
}
.background(color)
}
}
- 飛んでいくと背景が赤くなります
Swipeで消えるカードをずらしながら配置する
public struct StackSwipableCardView<Sources: RandomAccessCollection, CardContent: View> : View where Sources.Element : Identifiable {
private let sources: Sources
private let cardContent: (Sources.Element) -> CardContent
private let onEndedMove: (Sources.Element, CGSize) -> CardViewEndedMoveAction
private let onThrowAway: (Sources.Element) -> Void
private var configuration: StackSwipableCardConfiguration? = nil
public init(_ sources: Sources,
@ViewBuilder cardContent: @escaping (Sources.Element) -> CardContent,
onEndedMove: @escaping (Sources.Element, CGSize) -> CardViewEndedMoveAction,
onThrowAway: @escaping (Sources.Element) -> Void,
configuration: StackSwipableCardConfiguration? = nil) {
self.sources = sources
self.cardContent = cardContent
self.onEndedMove = onEndedMove
self.onThrowAway = onThrowAway
self.configuration = configuration
}
public var body: some View {
GeometryReader { (proxy: GeometryProxy) in
ZStack {
ForEach(Array(self.sources.reversed().enumerated()), id: \.1.id) { (i: Int, source: Sources.Element) in
SwipableCardView(source: source,
configuration: self.configuration ?? .makeDefault(sourcesSize: self.sources.count, index: i, proxy: proxy),
cardContent: self.cardContent,
onEndedMove: self.onEndedMove,
onThrowAway: self.onThrowAway)
}
.animation(.spring())
}
}
}
}
- これで完成です!
Discussion
コメント失礼します。
上記のソースコードを参考に勉強しているのですが、どうしてもエラーが出てしまいます。
どうか解説していただけないでしょうか?