Skip to content

Commit

Permalink
✨ Add support for swipe-to-dismiss of slideouts
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt committed Sep 30, 2024
1 parent 5dd8595 commit 5404ba9
Show file tree
Hide file tree
Showing 2 changed files with 178 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// ExperienceWrapperViewController+DragDirection.swift
// AppcuesKit
//
// Created by Matt on 2024-09-27.
// Copyright © 2024 Appcues. All rights reserved.
//

import SwiftUI

@available(iOS 13.0, *)
extension ExperienceWrapperViewController {
struct DragDirection {
enum Edge {
case left, right, up, down
}

let xDirection: Edge
let yDirection: Edge

private let initialPoint: CGPoint
private let newPoint: CGPoint

init(initial: CGPoint, new: CGPoint) {
self.initialPoint = initial
self.newPoint = new
self.xDirection = new.x > initial.x ? .right : .left
self.yDirection = new.y > initial.y ? .down : .up
}

/// Determine which edges are eligible for drag-to-dismiss.
static func allowedEdges(horizontalAlignment: HorizontalAlignment, verticalAlignment: VerticalAlignment) -> Set<Edge> {
switch (horizontalAlignment, verticalAlignment) {
case (.leading, _):
return [.left]
case (.trailing, _):
return [.right]
case (_, .top):
return [.up]
case (_, .bottom),
(.center, .center):
return [.down]
default:
return []
}
}

/// Calculate the new center point, applying rubber-banding if dragging towards an ineligible edge.
func newPoint(allowedEdges: Set<Edge>) -> CGPoint {
let newX: CGFloat
let newY: CGFloat

if allowedEdges.contains(xDirection) {
newX = newPoint.x
} else {
let deltaX = newPoint.x - initialPoint.x
newX = initialPoint.x + (deltaX > 0 ? pow(deltaX, 0.5) : -pow(-deltaX, 0.5))
}

if allowedEdges.contains(yDirection) {
newY = newPoint.y
} else {
let deltaY = newPoint.y - initialPoint.y
newY = initialPoint.y + (deltaY > 0 ? pow(deltaY, 0.5) : -pow(-deltaY, 0.5))
}

return CGPoint(x: newX, y: newY)
}

/// Confirm if the new position will be entirely outsize the visible bounds on an eligible edge.
func isSufficientToDismiss(allowedEdges: Set<Edge>, size: CGRect, bounds: CGRect) -> Bool {
if allowedEdges.contains(.left) && newPoint.x + size.width / 2 < bounds.minX {
return true
}

if allowedEdges.contains(.right) && newPoint.x - size.width / 2 > bounds.maxX {
return true
}

if allowedEdges.contains(.up) && newPoint.y + size.height / 2 < bounds.minY {
return true
}

if allowedEdges.contains(.down) && newPoint.y - size.height / 2 > bounds.maxY {
return true
}

return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import UIKit
import SwiftUI

@available(iOS 13.0, *)
internal class ExperienceWrapperViewController<BodyView: ExperienceWrapperView>: UIViewController, UIViewControllerTransitioningDelegate {
Expand All @@ -15,6 +16,11 @@ internal class ExperienceWrapperViewController<BodyView: ExperienceWrapperView>:

private let experienceContainerViewController: UIViewController

// Values for pan gesture handling
private var allowedEdges: Set<DragDirection.Edge> = []
private var initialOffset: CGPoint = .zero
private var initialCenter: CGPoint = .zero

// Only set for bottom-aligned modals. Need access to the constant value for keyboard avoidance.
var bottomConstraint: NSLayoutConstraint?

Expand Down Expand Up @@ -80,6 +86,15 @@ internal class ExperienceWrapperViewController<BodyView: ExperienceWrapperView>:
transitioningDelegate = self
let animator = ExperienceWrapperSlideAnimator(view: bodyView, edge: edge)
slideAnimationController = animator

allowedEdges = DragDirection.allowedEdges(
horizontalAlignment: HorizontalAlignment(string: style?.horizontalAlignment) ?? .center,
verticalAlignment: VerticalAlignment(string: style?.verticalAlignment) ?? .center
)

let panRecognizer = UIPanGestureRecognizer()
panRecognizer.addTarget(self, action: #selector(slideoutPanned(recognizer:)))
bodyView.contentWrapperView.addGestureRecognizer(panRecognizer)
}

return self
Expand Down Expand Up @@ -180,6 +195,78 @@ internal class ExperienceWrapperViewController<BodyView: ExperienceWrapperView>:
}
}

@objc
private func slideoutPanned(recognizer: UIPanGestureRecognizer) {
guard let panView = recognizer.view else { return }

let canDragToDismiss = !experienceContainerViewController.isModalInPresentation
let allowedEdges = canDragToDismiss ? self.allowedEdges : []

let touchPoint = recognizer.location(in: view)

switch recognizer.state {
case .began:
initialCenter = panView.center
initialOffset = CGPoint(
x: touchPoint.x - panView.center.x,
y: touchPoint.y - panView.center.y
)
case .changed:
let dragCenter = CGPoint(
x: touchPoint.x - initialOffset.x,
y: touchPoint.y - initialOffset.y
)

panView.center = DragDirection(initial: initialCenter, new: dragCenter)
.newPoint(allowedEdges: allowedEdges)
case .ended, .cancelled:
let gestureVelocity = recognizer.velocity(in: view)
let adjustedVelocity = DragDirection(initial: .zero, new: gestureVelocity)
.newPoint(allowedEdges: allowedEdges)

let projectedCenter = CGPoint(
x: panView.center.x + project(initialVelocity: adjustedVelocity.x),
y: panView.center.y + project(initialVelocity: adjustedVelocity.y)
)

let isSufficientToDismiss = DragDirection(initial: initialCenter, new: projectedCenter)
.isSufficientToDismiss(allowedEdges: allowedEdges, size: panView.bounds, bounds: bodyView.bounds)

let finalCenter = isSufficientToDismiss ? projectedCenter : initialCenter
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: adjustedVelocity.x, from: panView.center.x, to: finalCenter.x),
dy: relativeVelocity(forVelocity: adjustedVelocity.y, from: panView.center.y, to: finalCenter.y)
)
let timingParameters = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters)
animator.addAnimations {
panView.center = finalCenter

if isSufficientToDismiss {
self.bodyView.backdropView.alpha = 0
}
}
if isSufficientToDismiss {
animator.addCompletion { _ in
self.dismiss(animated: false)
}
}
animator.startAnimation()
default: break
}
}

/// Distance traveled after decelerating to zero velocity at a constant rate.
private func project(initialVelocity: CGFloat, decelerationRate: CGFloat = UIScrollView.DecelerationRate.normal.rawValue) -> CGFloat {
return (initialVelocity / 1_000) * decelerationRate / (1 - decelerationRate)
}

/// Calculates the relative velocity needed for the initial velocity of the animation.
private func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {
guard currentValue - targetValue != 0 else { return 0 }
return velocity / (targetValue - currentValue)
}

func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
Expand Down

0 comments on commit 5404ba9

Please sign in to comment.