🍞
SwiftUIでパンで半透明にしながら画面を閉じる実装
概要
- 以前作成した、UIPresentationControllerを使った、パンで半透明にしながら画面を閉じる実装をSwiftUIで書いてみる。
GitHub
実装
呼び出し例
- 呼び出し元のコードは以下の通り。
-
showingModal
によって遷移前の画面とモーダル画面の表示を切り替えている。 - モーダル画面側につけている
PanAndDismissModifier
が今回作成したものとなる。
import SwiftUI
struct ContentView: View {
@State private var showingModal = false
private let imageName = "image01"
var body: some View {
ZStack {
if showingModal {
// モーダル画面
Image(imageName)
.resizable()
.frame(width: 300, height: 300)
.modifier(PanAndDismissModifier(showingModal: $showingModal))
.zIndex(1)
}
Button {
withAnimation() {
showingModal = true
}
} label: {
// 遷移前の画面
Image(imageName)
.resizable()
.frame(width: 200, height: 200)
}
}
}
}
PanAndDismissModifier.swift
PanAndDismissModifier
-
PanAndDismissModifier
の内部ではさらにSwipableModifier
を実装している。それぞれの役割は以下の通り。-
PanAndDismissModifier
: ViewとBackgroundColorの管理を行う。 -
SwipableModifier
: Viewのパンのジェスチャーの管理を行う。
-
struct PanAndDismissModifier: ViewModifier {
@Binding var showingModal: Bool
@State private var imageDismissTransition: AnyTransition = .move(edge: .leading)
@State private var backgroundColorOpacity: Double = 1.0
var backgroundColor: Color = .black
func body(content: Content) -> some View {
ZStack {
if showingModal {
backgroundColor
.ignoresSafeArea()
.opacity(backgroundColorOpacity)
.transition(.opacity)
}
if showingModal {
content
.modifier(SwipableModifier(
backgroundColorOpacity: $backgroundColorOpacity,
imageDismissTransition: $imageDismissTransition,
onDismiss: {
withAnimation {
showingModal = false
}
}))
.zIndex(1)
.transition(.asymmetric(insertion: .opacity,
removal: imageDismissTransition))
}
}
}
}
- 背景のtransitionは
.opacity
・Viewは.move(edge: .bottom)
か.move(edge: .top)
としたい。 - 別々のtransitionの割当をしようと色々と試した所、下記のように冗長に分けて書くことで実現できた。(他に正攻法なやり方があれば教えて欲しいです)
if showingModal {
backgroundColor
...
}
if showingModal {
content
...
SwipableModifier
-
SwipableModifier
のコードは以下の通り。
private struct SwipableModifier: ViewModifier {
// MARK: - Properties
@Binding var backgroundColorOpacity: Double
@Binding var imageDismissTransition: AnyTransition
var onDismiss: () -> Void
@State private var offset: CGSize = .zero
private let dismissOffsetThreshold: CGFloat = 150
private let dismissVelocityThreshold: CGFloat = 20
// MARK: - View
func body(content: Content) -> some View {
GeometryReader { geometry in
ZStack {
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
content
.offset(y: offset.height)
.animation(.interactiveSpring(), value: offset)
}
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
.simultaneousGesture(swipeGesture(geometry: geometry))
}
}
// MARK: - Helpers
private func swipeGesture(geometry: GeometryProxy) -> some Gesture {
DragGesture()
.onChanged { gesture in
if gesture.translation.width < 50 {
offset = gesture.translation
}
// 画面の高さに対してどれだけスワイプされているかの比率
backgroundColorOpacity = 1 - abs(Double(offset.height / geometry.size.height))
}
.onEnded { value in
let velocity = value.predictedEndLocation.y - value.location.y
if velocity <= -dismissVelocityThreshold ||
offset.height < -dismissOffsetThreshold {
imageDismissTransition = .move(edge: .top)
onDismiss()
backgroundColorOpacity = 1.0
} else if velocity >= dismissVelocityThreshold ||
offset.height > dismissOffsetThreshold {
imageDismissTransition = .move(edge: .bottom)
onDismiss()
backgroundColorOpacity = 1.0
} else {
offset = .zero
backgroundColorOpacity = 1.0
}
}
}
}
-
.offset(y: offset.height)
でViewの位置をパンによって変化させている。 - また
ZStack
でViewの上に透明なViewを重ねて、画面全体でジェスチャーを受け取れるようにしている。
ZStack {
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
content
.offset(y: offset.height)
.animation(.interactiveSpring(), value: offset)
}
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
.simultaneousGesture(swipeGesture(geometry: geometry))
- パンの終了時にパンの速度が一定上もしくは一定以上の位置にある場合、transitionの方向を決定して
onDismiss
で遷移元に返している。
.onEnded { value in
let velocity = value.predictedEndLocation.y - value.location.y
if velocity <= -dismissVelocityThreshold ||
offset.height < -dismissOffsetThreshold {
imageDismissTransition = .move(edge: .top)
onDismiss()
backgroundColorOpacity = 1.0
} else if velocity >= dismissVelocityThreshold ||
offset.height > dismissOffsetThreshold {
imageDismissTransition = .move(edge: .bottom)
onDismiss()
backgroundColorOpacity = 1.0
} else {
offset = .zero
backgroundColorOpacity = 1.0
}
}
参考
import SwiftUI struct SwipeToDismissModifier: ViewModifier { var onDismiss: () -> Void @State private var offset: CGSize = .zero func body(content: Content) -> some View { content .offset(y: offset.height) .animation(.interactiveSpring(), value: offset) .simultaneousGesture( DragGesture() .onChanged { gesture in if gesture.translation.width < 50 { offset = gesture.translation } } .onEnded { _ in if abs(offset.height) > 100 { onDismiss() } else { offset = .zero } } ) } }
- [SwiftUI]画面遷移でスライドアニメーションをするには?
- SwiftUI でカスタムアニメーションを簡単に実装する
-
SwiftUI: Different Ways To Present Modal Views
-
zIndex
の概念
-
-
SwiftUI GeometryReader does not layout custom subviews in center
-
GeometryReader
を使うとViewがCenterにならない問題
-
-
SwiftUI で透明な View に Gesture を設定する
-
Color.clear
にGestureを設定する方法
-
-
Calculate velocity of DragGesture
- パン時のVelocityの算出
Discussion