Last active
October 12, 2023 15:03
-
-
Save Schadenfeude/f3065778728bf44676ff86deb317e9a5 to your computer and use it in GitHub Desktop.
Easy onSwipe extension for Swift Views
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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