SwiftUIでTinderっぽいスワイプ操作のCard View

21 min読了の目安(約12900字TECH技術記事

完成形

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

https://gist.github.com/fuziki/7b4fad13893894a1dbe80b3b0deacab0

実装

順番に実装して行きます

  1. スワイプする対象のカード
  2. DragGestureで移動するカード
  3. DragGestureで回転しながら横移動するカード
  4. Dragして指を離すと飛んでいくカード
  5. ずらしながらzstack
  6. 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())
            }
        }
    }
}
  • これで完成です!