Open9

SwiftUI: 左右にスライドしてページ切り替えするUI

kabeyakabeya

複数ページがあるような画面で、ページインジケータをドットで表示して、左右にスライドしてページ移動するというタイプのUIがあります。

これがSwiftUIだと難しい、という話です。
まずは、普通にやろうとするとどうなるか動画で見ます。

コードは以下のような感じです。

struct ContentView: View {
    @State var page: (current: Int, prev: Int) = (1, 0)
    @State var someText: String = ""
    @State var somethingOn: Bool = false
    @State var someValue: Double = 0.5
    
    var body: some View {
        VStack(spacing: 0) {
            switch page.current {
            case 1:
                Form {
                    Text("ページ 1")
                    TextField("何かの入力フィールド", text: $someText)
                }
                .transition(.slide)
            case 2:
                Form {
                    Text("ページ 2")
                    Toggle("何かのチェックボックス", isOn: $somethingOn)
                }
                .transition(.slide)
            case 3:
                Form {
                    Text("ページ 3")
                    VStack {
                        HStack {
                            Text("何かの値: \(someValue)")
                            Spacer()
                        }
                        Slider(value: $someValue)
                    }
                }
                .transition(.slide)
            default:
                Text("何のページか不明")
            }
            Spacer()
            HStack {
                ForEach(1 ... 3, id: \.self) { pageIdx in
                    Button("●") {
                        withAnimation {
                            self.page = (pageIdx, page.current)
                        }
                    }
                    .buttonStyle(.plain)
                    .foregroundStyle(.gray)
                    .font(.caption)
                    .disabled(self.page.current == pageIdx)
                }
            }
            .padding(.bottom, 8)
        
            HStack {
                Button("前へ") {
                    withAnimation {
                        let newPage: (current: Int, prev: Int) = (page.current - 1, page.current)
                        page = newPage
                    }
                }
                .disabled(page.current == 1)
                Spacer()
                Button("次へ") {
                    withAnimation {
                        let newPage: (current: Int, prev: Int) = (page.current + 1, page.current)
                        page = newPage
                    }
                }
                .disabled(page.current == 3)
            }
            .padding()
            .background(Color(white: 0.9))
        }
        .background(Color(uiColor: UIColor.systemGroupedBackground))
    }
}

#Preview {
    ContentView()
}

まず気付くのは「次へ」でスライドする方向が、感覚的に逆ということです。
「前へ」のスライド方向は、感覚的に合っています。

ただし方向自体は自前のAnyTransitionを作ることで変更できます。

kabeyakabeya

ただし方向自体は自前のAnyTransitionを作ることで変更できます。

private var reverseSlide: AnyTransition {
        return AnyTransition.asymmetric(
             insertion: .move(edge: .trailing),
             removal: .move(edge: .leading))
    }

各要素の.transition(.slide)を上記の関数を使って.transition(reverseSlide)に変えれば逆向きにスライドします。
AnyTransitionextensionにしても良いでしょう)

問題は、「次へ」と「前へ」のスライドが同じ方向になっているということです。

kabeyakabeya

問題は、「次へ」と「前へ」のスライドが同じ方向になっているということです。

で、これを解消するのがなかなか難しいのです。

この画面遷移のアニメーション効果は.transition()モディファイヤで指定するのですが、これは「表示されるときと非表示になるときの両方で効く」のです。

表示されるときは、自分が「次へ」で表示されたのか「前へ」で表示されたのかを知っています。
一方、表示された時点では、次に非表示になるのが「次へ」で非表示になるのか「前へ」で非表示になるのか知りません。

このため、非表示にするときのtransition.moveによるスライドにするというのは、仕組み的にたぶんできません。

kabeyakabeya

というわけで、非表示になるときのアニメーションはスライドではなく「徐々に透明になる」にします。
以下のような感じ。「次へ」「前へ」を押したときのアクションで、ページを進めるだけでなくどっちからどっちに進んだか分かるように直前の値を覚えさせておくようにして、それを用いて方向を判定します。

    private var reversibleSlide: AnyTransition {
        if page.current < page.prev {
            return AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .opacity)
        }
        else {
            return AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .opacity)
        }
    }

各要素の.transition(.slide)を上記の関数を使って.transition(reversibleSlide)に変えれば、なんとなくそれっぽくは動作します。

ちなみに現在のページと直前のページを別々の@Stateで管理すると、それぞれを更新するごとに再描画がかかるのでちょっとヤな感じになります。

kabeyakabeya

ただし、こうやって表示・非表示を切り替えるよりは、大きなビューを作ってページインジケータに合わせて横にスライドさせておいて、表示範囲をクリップするというほうが素直なのかも知れません。

kabeyakabeya

大きなビューを作ってページインジケータに合わせて横にスライドさせて

をやってみました。

struct ContentViewNoTransition: View {
    @State var page: Int = 1
    @State var someText: String = ""
    @State var somethingOn: Bool = false
    @State var someValue: Double = 0.5
    private let bottomHeight: CGFloat = 100
    
    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                HStack {
                    Form {
                        Text("ページ 1")
                        TextField("何かの入力フィールド", text: $someText)
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                    Form {
                        Text("ページ 2")
                        Toggle("何かのチェックボックス", isOn: $somethingOn)
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                    Form {
                        Text("ページ 3")
                        VStack {
                            HStack {
                                Text("何かの値: \(someValue)")
                                Spacer()
                            }
                            Slider(value: $someValue)
                        }
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                }
                .offset(x: geometry.size.width * CGFloat(2 - page))
                .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                //Divider()
                Spacer()
                HStack {
                    ForEach(1 ... 3, id: \.self) { pageIdx in
                        Button("●") {
                            withAnimation {
                                self.page = pageIdx
                            }
                        }
                        .buttonStyle(.plain)
                        .foregroundStyle(.gray)
                        .font(.caption)
                        .disabled(self.page == pageIdx)
                    }
                }
                .padding(.bottom, 8)
                
                HStack {
                    Button("前へ") {
                        withAnimation {
                            page -= 1
                        }
                    }
                    .disabled(page == 1)
                    Spacer()
                    Button("次へ") {
                        withAnimation {
                            page += 1
                        }
                    }
                    .disabled(page == 3)
                }
                .padding()
                .background(Color(white: 0.9))
            }
            .background(Color(uiColor: UIColor.systemGroupedBackground))
        }
    }
}


#Preview {
    ContentViewNoTransition()
}
kabeyakabeya

あとはScrollViewを使うという方法もあります。
iOS17からは.scrollPositionというモディファイヤが使えるようになっているので、それでコントロールできます。
また同じくiOS17から使えるようになった.containerRelativeFrameというモディファイヤを使うと、GeometryReader.frameを使わなくても枠のサイズ調整ができます。

struct ContentViewWithScrollView: View {
    @State var page: Int = 1
    @State var someText: String = ""
    @State var somethingOn: Bool = false
    @State var someValue: Double = 0.5
    @State var scrollPos: Int? = 1
    
    var body: some View {
        
        VStack(spacing: 0) {
            ScrollView(.horizontal, showsIndicators: false) {
               HStack {
                    Form {
                        Text("ページ 1")
                        TextField("何かの入力フィールド", text: $someText)
                    }
                    .containerRelativeFrame(.horizontal)
                    .id(1)
                    Form {
                        Text("ページ 2")
                        Toggle("何かのチェックボックス", isOn: $somethingOn)
                    }
                    .containerRelativeFrame(.horizontal)
                    .id(2)
                    Form {
                        Text("ページ 3")
                        VStack {
                            HStack {
                                Text("何かの値: \(someValue)")
                                Spacer()
                            }
                            Slider(value: $someValue)
                        }
                    }
                    .containerRelativeFrame(.horizontal)
                    .id(3)
                }
               .scrollTargetLayout()
            }
            .scrollPosition(id: $scrollPos)
            
            //Divider()
            Spacer()
            HStack {
                ForEach(1 ... 3, id: \.self) { pageIdx in
                    Button("●") {
                        withAnimation {
                            self.page = pageIdx
                            self.scrollPos = pageIdx
                        }
                    }
                    .buttonStyle(.plain)
                    .foregroundStyle(.gray)
                    .font(.caption)
                    .disabled(self.page == pageIdx)
                }
            }
            .padding(.bottom, 8)
            
            HStack {
                Button("前へ") {
                    withAnimation {
                        page -= 1
                        self.scrollPos = page
                    }
                }
                .disabled(page == 1)
                Spacer()
                Button("次へ") {
                    withAnimation {
                        page += 1
                        self.scrollPos = page
                    }
                }
                .disabled(page == 3)
            }
            .padding()
            .background(Color(white: 0.9))
        }
        .background(Color(uiColor: UIColor.systemGroupedBackground))
        .onChange(of: scrollPos) { (oldValue, newValue) in
            guard let newValue else { return }
            self.page = newValue
        }
    }
}


#Preview {
    ContentViewWithScrollView()
}

ドラッグでのスクロールができるようになるのですが、ページの途中で止めることができるので、善し悪しですね。
offsetで動かす方法だと、自分でDragGestureを使って実装しなければならない一方で、途中で止められないようにすることができます。

kabeyakabeya

offsetで動かす方法だと、自分でDragGestureを使って実装しなければならない

これも実装してみました。
こっちのほうがやはり良い感じですね。

struct ContentViewNoTransition2: View {
    @State var page: Int = 1
    @State var someText: String = ""
    @State var somethingOn: Bool = false
    @State var someValue: Double = 0.5
    private let bottomHeight: CGFloat = 100
    @GestureState var isPageChanged: Bool = false
    
    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0) {
                HStack {
                    Form {
                        Text("ページ 1")
                        TextField("何かの入力フィールド", text: $someText)
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                    Form {
                        Text("ページ 2")
                        Toggle("何かのチェックボックス", isOn: $somethingOn)
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                    Form {
                        Text("ページ 3")
                        VStack {
                            HStack {
                                Text("何かの値: \(someValue)")
                                Spacer()
                            }
                            Slider(value: $someValue)
                        }
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                }
                .gesture(DragGesture()
                    .updating($isPageChanged) { (value, state, _) in
                        if value.translation.width < -geometry.size.width * 0.2 && !isPageChanged {
                            withAnimation {
                                page += 1
                                state = true
                            }
                        }
                        else if geometry.size.width * 0.2 < value.translation.width && !isPageChanged {
                            withAnimation {
                                page -= 1
                                state = true
                            }
                        }
                    }
                )
                .offset(x: geometry.size.width * CGFloat(2 - page))
                .frame(width: geometry.size.width, height: geometry.size.height - bottomHeight)
                //Divider()
                Spacer()
                HStack {
                    ForEach(1 ... 3, id: \.self) { pageIdx in
                        Button("●") {
                            withAnimation {
                                self.page = pageIdx
                            }
                        }
                        .buttonStyle(.plain)
                        .foregroundStyle(.gray)
                        .font(.caption)
                        .disabled(self.page == pageIdx)
                    }
                }
                .padding(.bottom, 8)
                
                HStack {
                    Button("前へ") {
                        withAnimation {
                            page -= 1
                        }
                    }
                    .disabled(page == 1)
                    Spacer()
                    Button("次へ") {
                        withAnimation {
                            page += 1
                        }
                    }
                    .disabled(page == 3)
                }
                .padding()
                .background(Color(white: 0.9))
            }
            .background(Color(uiColor: UIColor.systemGroupedBackground))
        }
    }
}


#Preview {
    ContentViewNoTransition2()
}
kabeyakabeya

サブビューとかに分けて少し部品ぽくしました。

struct ContentViewNoTransition3: View {
    @State var page: Int = 1
    @State var someText: String = ""
    @State var somethingOn: Bool = false
    @State var someValue: Double = 0.5
    private let bottomHeight: CGFloat = 100
    
    var body: some View {
        GeometryReader { geometry in
            let width = geometry.size.width
            let height = geometry.size.height
            
            VStack(spacing: 0) {
                ScrollableHStack(minPage: 1, maxPage: 3, page: $page, width: width, height: height - bottomHeight) {
                    Form {
                        Text("ページ 1")
                        TextField("何かの入力フィールド", text: $someText)
                    }
                    .frame(width: width, height: height - bottomHeight)
                    Form {
                        Text("ページ 2")
                        Toggle("何かのチェックボックス", isOn: $somethingOn)
                    }
                    .frame(width: width, height: height - bottomHeight)
                    Form {
                        Text("ページ 3")
                        VStack {
                            HStack {
                                Text("何かの値: \(someValue)")
                                Spacer()
                            }
                            Slider(value: $someValue)
                        }
                    }
                    .frame(width: width, height: height - bottomHeight)
                }
                Spacer()
                PageIndicator(minPage: 1, maxPage: 3, page: $page)
                
                HStack {
                    Button("前へ") {
                        withAnimation {
                            page -= 1
                        }
                    }
                    .disabled(page == 1)
                    Spacer()
                    Button("次へ") {
                        withAnimation {
                            page += 1
                        }
                    }
                    .disabled(page == 3)
                }
                .padding()
                .background(Color(white: 0.9))
            }
            .background(Color(uiColor: UIColor.systemGroupedBackground))
        }
    }
}

struct ScrollableHStack<ViewT: View>: View {
    let minPage: Int
    let maxPage: Int
    @Binding var page: Int
    let width: CGFloat
    let height: CGFloat
    let content: () -> ViewT
    
    init(minPage: Int, maxPage: Int, page: Binding<Int>, width: CGFloat, height: CGFloat, @ViewBuilder content: @escaping () -> ViewT) {
        self.minPage = minPage
        self.maxPage = maxPage
        self._page = page
        self.width = width
        self.height = height
        self.content = content
    }
    
    var body: some View {
        HStack(spacing: 0) {
            let center = CGFloat(minPage + maxPage) / 2
            content()
                .offset(x: width * (center - CGFloat(page)))
                .draggablePage(page: $page, minPage: 1, maxPage: 3, width: width)
        }
        .frame(width: width, height: height)
    }
}

struct DraggablePageModifier: ViewModifier {
    @GestureState private var isPageChanged: Bool = false
    @Binding var page: Int
    let minPage: Int
    let maxPage: Int
    let width: CGFloat
    
    func body(content: Content) -> some View {
        content
            .gesture(DragGesture()
                .updating($isPageChanged) { (value, state, _) in
                    if value.translation.width < -width * 0.2 && !isPageChanged && page < maxPage {
                        withAnimation {
                            page += 1
                            state = true
                        }
                    }
                    else if width * 0.2 < value.translation.width && !isPageChanged && minPage < page {
                        withAnimation {
                            page -= 1
                            state = true
                        }
                    }
                }
            )
    }
}

extension View {
    func draggablePage(page: Binding<Int>, minPage: Int, maxPage: Int, width: CGFloat) -> some View {
        self.modifier(DraggablePageModifier(page: page, minPage: minPage, maxPage: maxPage, width: width))
    }
}


struct PageIndicator: View {
    var minPage: Int
    var maxPage: Int
    @Binding var page: Int
    
    var body: some View {
        HStack {
            ForEach(minPage ... maxPage, id: \.self) { pageIdx in
                Button("●") {
                    withAnimation {
                        self.page = pageIdx
                    }
                }
                .buttonStyle(.plain)
                .foregroundStyle(self.page == pageIdx ? Color(white: 0.2) : Color(white: 0.8))
                .font(.caption)
                .disabled(self.page == pageIdx)
            }
        }
        .padding(.bottom, 8)
    }
}

#Preview {
    ContentViewNoTransition3()
}

動きは以下のようになります。