Skip to content

Instantly share code, notes, and snippets.

@Schadenfeude
Last active October 12, 2023 15:03
Show Gist options
  • Save Schadenfeude/f3065778728bf44676ff86deb317e9a5 to your computer and use it in GitHub Desktop.
Save Schadenfeude/f3065778728bf44676ff86deb317e9a5 to your computer and use it in GitHub Desktop.
Easy onSwipe extension for Swift Views
import SwiftUI
// Example usage
let canDelete = true
let rowId = UUID().uuidString
HStack {
Text("Some row we want to swipe")
}.onSwipe(
isEnabled: canDelete,
action: { deleteRow(rowId: rowId) },
content: {
Text("Delete")
}
)
//
// SwipeView.swift
//
// Created by Daniel Milkov on 12.10.23.
// Copyright © 2023 orgName. All rights reserved.
//
import SwiftUI
/** Use via the View's ``onSwipe(edge:isEnabled:action:content:)`` extension */
// Logic based on https://developer.apple.com/forums/thread/123034
struct SwipeView<TopView: View, BottomView: View>: View {
let edge: HorizontalEdge
let isEnabled: Bool
let action: () -> Void
let topView: () -> TopView
let bottomView: () -> BottomView
@GestureState private var isDragging: Bool = false
@State private var gestureState: GestureStatus = .idle
@State private var offset: CGSize = .zero
var body: some View {
ZStack {
HStack {
let isLeading = edge == .leading
let paddingSide = isLeading ? Edge.Set.leading : Edge.Set.trailing
if (!isLeading) { Spacer() }
bottomView()
.padding(paddingSide, 8)
if (isLeading) { Spacer() }
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
.background(.blue)
topView()
.gesture(
DragGesture(minimumDistance: 50.0, coordinateSpace: .local)
.updating($isDragging) { _, isDragging, _ in
isDragging = true
}
.onChanged(onDragChange(_:))
.onEnded(onDragEnded(_:))
)
.onChange(of: gestureState) { state in
guard state == .started else { return }
gestureState = .active
}
.onChange(of: isDragging) { value in
if value, gestureState != .started {
gestureState = .started
onStart()
} else if !value, gestureState != .ended {
gestureState = .cancelled
onCancel()
}
}
.animation(.spring, value: offset.width)
.offset(x: offset.width)
}
}
private func onDragChange(_ value: DragGesture.Value) {
guard gestureState == .started || gestureState == .active else { return }
onUpdate(translation: value.translation)
}
private func onDragEnded(_ value: DragGesture.Value) {
gestureState = .ended
onEnd(
translation: value.translation,
velocity: value.velocity
)
}
private func onStart() {
// Do nothing for now
}
private func onCancel() {
offset = .zero
}
private func onUpdate(translation: CGSize) {
if (!allowGesture(isEnabled: isEnabled, edge: edge, translation: translation)) { return }
offset = translation
}
private func onEnd(
translation: CGSize,
velocity: CGSize
) {
if (gestureSuccessful(edge: edge, translation: translation, velocity: velocity)) {
let screenWidth = UIScreen.main.bounds.size.width
let animationEnd = (edge == .leading) ? screenWidth : -screenWidth
// Animate TopView going to the end of the screen
offset = CGSize(width: animationEnd, height: 0)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
// Give the animation time to play before invoking the action
action()
}
} else {
// Revert TopView to initial position
offset = .zero
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// Reset the position of the TopView after the animation's played
offset = .zero
}
}
private func allowGesture(
isEnabled: Bool,
edge: HorizontalEdge,
translation: CGSize
) -> Bool {
if (!isEnabled) { return false }
return switch(translation.width, translation.height) {
case (...0, -30...30): edge == .trailing
case (0..., -30...30): edge == .leading
default: false
}
}
private func gestureSuccessful(
edge: HorizontalEdge,
translation: CGSize,
velocity: CGSize
) -> Bool {
let minSwipeDistance = (edge == .leading) ? 100.0 : -100.0
let minSwipeVelocity = (edge == .leading) ? 1000.0 : -1000.0
return switch(edge) {
case .leading: translation.width > minSwipeDistance && velocity.width > minSwipeVelocity
case .trailing: translation.width < minSwipeDistance && velocity.width < minSwipeVelocity
}
}
private enum GestureStatus: Equatable {
case idle
case started
case active
case ended
case cancelled
}
}
import SwiftUI
extension View {
public func onSwipe<T>(
edge: HorizontalEdge = .leading,
isEnabled: Bool = true,
action: @escaping () -> Void,
@ViewBuilder content: @escaping () -> T
) -> some View where T : View {
SwipeView(
edge: edge,
isEnabled: isEnabled,
action: action,
topView: { self },
bottomView: { content() }
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment