🍞

SwiftUIでパンで半透明にしながら画面を閉じる実装

2022/06/19に公開

概要

image

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

Discussion