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

複数ページがあるような画面で、ページインジケータをドットで表示して、左右にスライドしてページ移動するというタイプの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
を作ることで変更できます。

ただし方向自体は自前のAnyTransitionを作ることで変更できます。
private var reverseSlide: AnyTransition {
return AnyTransition.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
}
各要素の.transition(.slide)
を上記の関数を使って.transition(reverseSlide)
に変えれば逆向きにスライドします。
(AnyTransition
のextension
にしても良いでしょう)
問題は、「次へ」と「前へ」のスライドが同じ方向になっているということです。

問題は、「次へ」と「前へ」のスライドが同じ方向になっているということです。
で、これを解消するのがなかなか難しいのです。
この画面遷移のアニメーション効果は.transition()
モディファイヤで指定するのですが、これは「表示されるときと非表示になるときの両方で効く」のです。
表示されるときは、自分が「次へ」で表示されたのか「前へ」で表示されたのかを知っています。
一方、表示された時点では、次に非表示になるのが「次へ」で非表示になるのか「前へ」で非表示になるのか知りません。
このため、非表示にするときのtransition
を.move
によるスライドにするというのは、仕組み的にたぶんできません。

というわけで、非表示になるときのアニメーションはスライドではなく「徐々に透明になる」にします。
以下のような感じ。「次へ」「前へ」を押したときのアクションで、ページを進めるだけでなくどっちからどっちに進んだか分かるように直前の値を覚えさせておくようにして、それを用いて方向を判定します。
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
で管理すると、それぞれを更新するごとに再描画がかかるのでちょっとヤな感じになります。

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

大きなビューを作ってページインジケータに合わせて横にスライドさせて
をやってみました。
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()
}

あとは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
を使って実装しなければならない一方で、途中で止められないようにすることができます。

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()
}

サブビューとかに分けて少し部品ぽくしました。
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()
}
動きは以下のようになります。