SwiftUIで本をめくるようなUI

本のようなUIを作る需要があり、作ってみたらいい感じになったので共有。

![Screen Recording 2025-03-31 at 6.54.55.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/025a6596-1404-4392-b12b-6f791a148438.gif)


## 1. 使用例

本とそれを構成するページの表裏をDSL的に記述していく。
いい感じ。

```swift
struct BookView: View {
    var body: some View {
        NavigationStack {
            VStack {
                Book(config: .init()) {
                    Page {
                        fistFront
                    } back: {
                        firstBack
                    }
                    Page {
                        secondFront
                    } back: {
                        secondBack
                    }
                }
            }
            .padding(15)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.gray.opacity(0.15))
            .navigationTitle("Book View")
        }
    }
    
    var fistFront: some View {
        VStack {
            Text("Book Title")
                .font(.largeTitle)
            Spacer()
            Text("Author Name").bold()
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.cyan)
    }
    
    var firstBack: some View {
        VStack {
            Text("1st back")
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.background)
    }
    
    var secondFront: some View {
        VStack {
            Text("2nd front")
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.background)
    }
    
    var secondBack: some View {
        VStack {
            Text("last page")
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.background)
    }
}
```

## 2. Bookの実装

`@resultBuilder`を使っているのが肝。

```swift
@resultBuilder
struct PageBuilder {
    static func buildBlock(_ pages: Page...) -> [Page] {
        return pages
    }
}

struct Book: View, Animatable {
    var config: Config = .init()
    var pages: [Page]
    @State private var offset: CGFloat = 0
    
    init(config: Config, @PageBuilder content: () -> [Page]) {
        self.config = config
        self.pages = content()
    }
    
    var body: some View {
        GeometryReader {
            let size = $0.size
            
            ZStack {
                ForEach(Array(pages.enumerated()), id: \.element.id) { index, page in
                    OpenablePage(bookOffset: $offset, size: size, page: page, index: Double(index))
                }
            }
        }
        .frame(width: config.width, height: config.height)
        .offset(x: offset)
    }
    
    struct Config {
        var width: CGFloat = 350
        var height: CGFloat = 500
    }
}
```

## Pageの実装

`scaleEffect(x: -1)`で裏面のViewを反転させ、`overlay`で重ねる。
`rotation3DEffect`を使い、90度回転したところで裏を表示させることでフリップする感じを演出。
縦に長いiPhoneの画面をフルに活用するために、画面には両開きで表示するのではなく、1ページだけを表示する。
`offset`を使って、本自体をスライド→ページをめくる　を繰り返す。

```swift
struct Page: Identifiable {
    var id = UUID()
    var front: AnyView
    var back: AnyView
    
    init<Front: View, Back: View>(
        @ViewBuilder front: @escaping () -> Front,
        @ViewBuilder back: @escaping () -> Back
    ) {
        self.front = AnyView(front())
        self.back = AnyView(back())
    }
}

struct OpenablePage: View {
    @Binding var bookOffset: CGFloat
    let size: CGSize
    let page: Page
    let index: Double

    @State private var progress: Double = 0.0
    @State private var animationInProgress = false

    var body: some View {
        FlipPage(progress: progress, size: size, front: page.front, back: page.back, index: index)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        if animationInProgress { return }
                        let translationWidth = value.translation.width
                        let isDraggingToLeft = translationWidth < 0
                        let frontIsDisplayed = bookOffset == 0
                        let threshold = size.width / 3
                        if isDraggingToLeft && -translationWidth > threshold {
                            animationInProgress = true
                            withAnimation(.easeOut(duration: 1)) {
                                if frontIsDisplayed {
                                    bookOffset = size.width
                                    progress = 1.0
                                } else {
                                    bookOffset = 0
                                }
                            } completion: {
                                animationInProgress = false
                            }
                        }
                        if !isDraggingToLeft && translationWidth > threshold {
                            animationInProgress = true
                            withAnimation(.easeIn(duration: 1)) {
                                if frontIsDisplayed {
                                    bookOffset = size.width
                                } else {
                                    bookOffset = 0
                                    progress = 0.0
                                }
                            } completion: {
                                animationInProgress = false
                            }
                        }
                    }
            )
    }

    struct FlipPage: View, Animatable {
        var progress: Double
        let size: CGSize
        let front: AnyView
        let back: AnyView
        let index: Double

        var animatableData: Double {
            get { progress }
            set { progress = newValue }
        }

        var body: some View {
            let rotation = -180 * progress

            front
                .frame(width: size.width, height: size.height)
                .overlay {
                    if rotation < -90 {
                        back
                            .frame(width: size.width, height: size.height)
                            .scaleEffect(x: -1)
                            .transition(.identity)
                    }
                }
                .roundTrailingCorner(10)
                .shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 0)
                .rotation3DEffect(
                    .degrees(rotation),
                    axis: (x: 0, y: 1, z: 0),
                    anchor: .leading,
                    perspective: 0.3
                )
                .zIndex(progress == 0 ? -index: index)
        }
    }
}

fileprivate extension View {
    func roundTrailingCorner(_ radius: CGFloat) -> some View {
        modifier(RoundTrailingCorner(radius: radius))
    }
}

fileprivate struct RoundTrailingCorner: ViewModifier {
    let radius: CGFloat
    
    func body(content: Content) -> some View {
        content
            .clipShape(
                .rect(
                    topLeadingRadius: 0,
                    bottomLeadingRadius: 0,
                    bottomTrailingRadius: radius,
                    topTrailingRadius: radius
                )
            )
    }
}
```