mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
252
Yattee/Views/Components/SwipeActionModifier.swift
Normal file
252
Yattee/Views/Components/SwipeActionModifier.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
//
|
||||
// SwipeActionModifier.swift
|
||||
// Yattee
|
||||
//
|
||||
// ViewModifier implementing swipe-to-reveal action buttons.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
extension View {
|
||||
/// On tvOS, swipe actions are not supported - returns the view unmodified.
|
||||
@ViewBuilder
|
||||
func swipeActions(
|
||||
config: SwipeActionConfig = .init(),
|
||||
@SwipeActionBuilder actions: () -> [SwipeAction]
|
||||
) -> some View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
extension View {
|
||||
/// Adds swipe actions to a view (trailing swipe to reveal action buttons).
|
||||
@ViewBuilder
|
||||
func swipeActions(
|
||||
config: SwipeActionConfig = .init(),
|
||||
@SwipeActionBuilder actions: () -> [SwipeAction]
|
||||
) -> some View {
|
||||
modifier(SwipeActionModifier(config: config, actions: actions()))
|
||||
}
|
||||
|
||||
/// Adds swipe actions to a view using an array directly.
|
||||
/// Use this variant when building actions dynamically.
|
||||
@ViewBuilder
|
||||
func swipeActions(
|
||||
config: SwipeActionConfig = .init(),
|
||||
actionsArray: [SwipeAction]
|
||||
) -> some View {
|
||||
modifier(SwipeActionModifier(config: config, actions: actionsArray))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state ensuring only one row can be swiped open at a time.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SwipeActionSharedState {
|
||||
static let shared = SwipeActionSharedState()
|
||||
|
||||
/// The ID of the currently active (swiped open) row, if any.
|
||||
var activeSwipeAction: String?
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
/// ViewModifier that implements the swipe behavior with action buttons.
|
||||
struct SwipeActionModifier: ViewModifier {
|
||||
var config: SwipeActionConfig
|
||||
var actions: [SwipeAction]
|
||||
|
||||
// View state
|
||||
@State private var resetPositionTrigger = false
|
||||
@State private var offsetX: CGFloat = 0
|
||||
@State private var lastStoredOffsetX: CGFloat = 0
|
||||
@State private var bounceOffset: CGFloat = 0
|
||||
@State private var progress: CGFloat = 0
|
||||
|
||||
// Scroll tracking for auto-close on scroll
|
||||
@State private var currentScrollOffset: CGFloat = 0
|
||||
@State private var storedScrollOffset: CGFloat?
|
||||
|
||||
// Shared state reference (computed to avoid inclusion in memberwise init)
|
||||
private var sharedState: SwipeActionSharedState { SwipeActionSharedState.shared }
|
||||
@State private var currentID = UUID().uuidString
|
||||
|
||||
// iOS 17 fallback gesture state
|
||||
@GestureState private var isActive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if #available(iOS 18, *) {
|
||||
swipeableContent(content)
|
||||
.gesture(
|
||||
SwipeGesture(
|
||||
onBegan: { gestureDidBegan() },
|
||||
onChange: { value in gestureDidChange(translation: value.translation) },
|
||||
onEnded: { value in gestureDidEnded(translation: value.translation, velocity: value.velocity) }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
fallbackSwipeableContent(content)
|
||||
}
|
||||
#else
|
||||
fallbackSwipeableContent(content)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: resetPositionTrigger) { _, _ in
|
||||
reset()
|
||||
}
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
$0.frame(in: .scrollView).minY
|
||||
} action: { newValue in
|
||||
if let storedScrollOffset, storedScrollOffset != newValue {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
.onChange(of: sharedState.activeSwipeAction) { _, newValue in
|
||||
if newValue != currentID && offsetX != 0 {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback using DragGesture for iOS 17 and macOS.
|
||||
@ViewBuilder
|
||||
private func fallbackSwipeableContent(_ content: Content) -> some View {
|
||||
swipeableContent(content)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($isActive) { _, out, _ in
|
||||
out = true
|
||||
}
|
||||
.onChanged { value in
|
||||
gestureDidChange(translation: value.translation)
|
||||
}
|
||||
.onEnded { value in
|
||||
gestureDidEnded(
|
||||
translation: value.translation,
|
||||
velocity: CGSize(width: value.velocity.width, height: value.velocity.height)
|
||||
)
|
||||
}
|
||||
)
|
||||
.onChange(of: isActive) { oldValue, newValue in
|
||||
if newValue {
|
||||
gestureDidBegan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The content view with swipe overlay and offset applied.
|
||||
@ViewBuilder
|
||||
private func swipeableContent(_ content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
Rectangle()
|
||||
.foregroundStyle(.clear)
|
||||
.containerRelativeFrame(config.occupiesFullWidth ? .horizontal : .init())
|
||||
.overlay(alignment: .trailing) {
|
||||
actionsView
|
||||
}
|
||||
}
|
||||
.compositingGroup()
|
||||
.offset(x: offsetX)
|
||||
.offset(x: bounceOffset)
|
||||
.mask {
|
||||
Rectangle()
|
||||
.containerRelativeFrame(config.occupiesFullWidth ? .horizontal : .init())
|
||||
}
|
||||
}
|
||||
|
||||
/// The action buttons that slide in from the trailing edge.
|
||||
@ViewBuilder
|
||||
private var actionsView: some View {
|
||||
ZStack {
|
||||
ForEach(actions.indices, id: \.self) { index in
|
||||
let action = actions[index]
|
||||
|
||||
GeometryReader { proxy in
|
||||
let size = proxy.size
|
||||
let spacing = config.spacing * CGFloat(index)
|
||||
let offset = (CGFloat(index) * size.width) + spacing
|
||||
|
||||
Button {
|
||||
action.action { [self] in
|
||||
resetPositionTrigger.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: action.symbolImage)
|
||||
.font(action.font)
|
||||
.foregroundStyle(action.tint)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(action.background, in: Circle())
|
||||
}
|
||||
.offset(x: offset * progress)
|
||||
}
|
||||
.frame(width: action.size.width, height: action.size.height)
|
||||
}
|
||||
}
|
||||
.visualEffect { content, proxy in
|
||||
content.offset(x: proxy.size.width)
|
||||
}
|
||||
.offset(x: config.leadingPadding)
|
||||
.opacity(progress == 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handlers
|
||||
|
||||
private func gestureDidBegan() {
|
||||
storedScrollOffset = lastStoredOffsetX
|
||||
sharedState.activeSwipeAction = currentID
|
||||
}
|
||||
|
||||
private func gestureDidChange(translation: CGSize) {
|
||||
offsetX = min(max(translation.width + lastStoredOffsetX, -maxOffsetWidth), 0)
|
||||
progress = -offsetX / maxOffsetWidth
|
||||
bounceOffset = min(translation.width - (offsetX - lastStoredOffsetX), 0) / 10
|
||||
}
|
||||
|
||||
private func gestureDidEnded(translation: CGSize, velocity: CGSize) {
|
||||
let endTarget = velocity.width + offsetX
|
||||
|
||||
withAnimation(.snappy(duration: 0.3, extraBounce: 0)) {
|
||||
if -endTarget > (maxOffsetWidth * 0.6) {
|
||||
// Snap open
|
||||
offsetX = -maxOffsetWidth
|
||||
bounceOffset = 0
|
||||
progress = 1
|
||||
} else {
|
||||
// Reset to closed
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
lastStoredOffsetX = offsetX
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
withAnimation(.snappy(duration: 0.3, extraBounce: 0)) {
|
||||
offsetX = 0
|
||||
lastStoredOffsetX = 0
|
||||
progress = 0
|
||||
bounceOffset = 0
|
||||
}
|
||||
|
||||
storedScrollOffset = nil
|
||||
}
|
||||
|
||||
/// Maximum offset width based on action sizes and spacing.
|
||||
private var maxOffsetWidth: CGFloat {
|
||||
let totalActionSize = actions.reduce(CGFloat.zero) { result, action in
|
||||
result + action.size.width
|
||||
}
|
||||
|
||||
let spacing = config.spacing * CGFloat(actions.count - 1)
|
||||
return totalActionSize + spacing + config.leadingPadding + config.trailingPadding
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user