diff --git a/Sources/SwipeActions/SwipeActions.swift b/Sources/SwipeActions/SwipeActions.swift index b22f00c..a6dc3d4 100644 --- a/Sources/SwipeActions/SwipeActions.swift +++ b/Sources/SwipeActions/SwipeActions.swift @@ -10,6 +10,11 @@ import SwiftUI public typealias Leading = Group where V:View public typealias Trailing = Group where V:View +public enum MenuType { + case slided /// hstacked + case swiped /// zstacked +} + public struct SwipeAction: ViewModifier { enum VisibleButton { @@ -22,23 +27,39 @@ public struct SwipeAction: ViewModifier { @State private var oldOffset: CGFloat = 0 @State private var visibleButton: VisibleButton = .none + /** + To detect if drag gesture is ended because of known issue that drag gesture onEnded not called: + https://stackoverflow.com/questions/58807357/detect-draggesture-cancelation-in-swiftui + */ + @GestureState private var dragGestureActive: Bool = false + @State private var maxLeadingOffset: CGFloat = .zero @State private var minTrailingOffset: CGFloat = .zero - @State private var contentHeight: CGFloat = .zero + + /** + For lazy views: because of measuring size occurred every onAppear + */ + @State private var maxLeadingOffsetIsCounted: Bool = false + @State private var minTrailingOffsetIsCounted: Bool = false + + private let menuTyped: MenuType private let leadingSwipeView: Group? private let trailingSwipeView: Group? - init(@ViewBuilder _ content: @escaping () -> TupleView<(Leading, Trailing)>) { + init(menu: MenuType, @ViewBuilder _ content: @escaping () -> TupleView<(Leading, Trailing)>) { + menuTyped = menu leadingSwipeView = content().value.0 trailingSwipeView = content().value.1 } - init(@ViewBuilder leading: @escaping () -> V1) { + init(menu: MenuType, @ViewBuilder leading: @escaping () -> V1) { + menuTyped = menu leadingSwipeView = Group { leading() } trailingSwipeView = nil } - init(@ViewBuilder trailing: @escaping () -> V2) { + init(menu: MenuType, @ViewBuilder trailing: @escaping () -> V2) { + menuTyped = menu trailingSwipeView = Group { trailing() } leadingSwipeView = nil } @@ -49,59 +70,116 @@ public struct SwipeAction: ViewModifier { oldOffset = 0 } - public func body(content: Content) -> some View { - ZStack { - content - .measureSize { - contentHeight = $0.height + var leadingView: some View { + leadingSwipeView + .measureSize { + if !maxLeadingOffsetIsCounted { + maxLeadingOffset = maxLeadingOffset + $0.width } - .contentShape(Rectangle()) ///otherwise swipe won't work in vacant area - .offset(x: offset) - .gesture(DragGesture(minimumDistance: 15, coordinateSpace: .local) - .onChanged({ (value) in - let totalSlide = value.translation.width + oldOffset - if (0...Int(maxLeadingOffset) ~= Int(totalSlide)) || (Int(minTrailingOffset)...0 ~= Int(totalSlide)) { //left to right slide - withAnimation{ - offset = totalSlide - } + } + .onAppear { + /** + maxLeadingOffsetIsCounted for of lazy views + */ + maxLeadingOffsetIsCounted = true + } + } + + var trailingView: some View { + trailingSwipeView + .measureSize { + if !minTrailingOffsetIsCounted { + minTrailingOffset = (abs(minTrailingOffset) + $0.width) * -1 + } + } + .onAppear { + /** + maxLeadingOffsetIsCounted for of lazy views + */ + minTrailingOffsetIsCounted = true + } + } + + var swipedMenu: some View { + HStack(spacing: 0) { + leadingView + Spacer() + trailingView + } + } + + var slidedMenu: some View { + HStack(spacing: 0) { + leadingView + .offset(x: (-1 * maxLeadingOffset) + offset) + Spacer() + trailingView + .offset(x: (-1 * minTrailingOffset) + offset) + } + } + + func gesturedContent(content: Content) -> some View { + content + .contentShape(Rectangle()) ///otherwise swipe won't work in vacant area + .offset(x: offset) + .gesture( + DragGesture(minimumDistance: 15, coordinateSpace: .local) + .updating($dragGestureActive) { value, state, transaction in + state = true } - ///can update this logic to set single button action with filled single button background if scrolled more then buttons width - }) - .onEnded({ value in - withAnimation { - if visibleButton == .left && value.translation.width < -20 { ///user dismisses left buttons - reset() - } else if visibleButton == .right && value.translation.width > 20 { ///user dismisses right buttons - reset() - } else if offset > 25 || offset < -25 { ///scroller more then 50% show button - if offset > 0 { - visibleButton = .left - offset = maxLeadingOffset + .onChanged { value in + let totalSlide = value.translation.width + oldOffset + + if (0...Int(maxLeadingOffset) ~= Int(totalSlide)) || (Int(minTrailingOffset)...0 ~= Int(totalSlide)) { + withAnimation { + offset = totalSlide + } + } + }.onEnded { value in + print("gesture is ended!") + withAnimation { + if visibleButton == .left && value.translation.width < -20 { ///user dismisses left buttons + reset() + } else if visibleButton == .right && value.translation.width > 20 { ///user dismisses right buttons + reset() + } else if offset > 20 || offset < -20 { ///scroller more then 50% show button + if offset > 0 { + visibleButton = .left + offset = maxLeadingOffset + } else { + visibleButton = .right + offset = minTrailingOffset + } + oldOffset = offset + ///Bonus Handling -> set action if user swipe more then x px } else { - visibleButton = .right - offset = minTrailingOffset + reset() + } + } + }) + .valueChanged(of: dragGestureActive) { newIsActiveValue in + if newIsActiveValue == false { + withAnimation { + if visibleButton == .none { + reset() } - oldOffset = offset - ///Bonus Handling -> set action if user swipe more then x px - } else { - reset() } } - })) - HStack(alignment: .center, spacing: 0) { - leadingSwipeView - .measureSize { - maxLeadingOffset = maxLeadingOffset + $0.width - } - .offset(x: (-1 * maxLeadingOffset) + offset) - Spacer() - trailingSwipeView - .measureSize { - minTrailingOffset = (abs(minTrailingOffset) + $0.width) * -1 - } - .offset(x: (-1 * minTrailingOffset) + offset) + } + } + + public func body(content: Content) -> some View { + switch menuTyped { + case .slided: + ZStack { + gesturedContent(content: content) + slidedMenu + } + case .swiped: + ZStack { + swipedMenu + gesturedContent(content: content) } } - .frame(height: contentHeight) } } diff --git a/Sources/SwipeActions/ViewModifiers/SwipeActionModifier.swift b/Sources/SwipeActions/ViewModifiers/SwipeActionModifier.swift index 09959d2..e7c2afa 100644 --- a/Sources/SwipeActions/ViewModifiers/SwipeActionModifier.swift +++ b/Sources/SwipeActions/ViewModifiers/SwipeActionModifier.swift @@ -10,17 +10,17 @@ import SwiftUI public extension View { @ViewBuilder - func addSwipeAction(@ViewBuilder _ content: @escaping () -> TupleView<(Leading, Trailing)>) -> some View { - self.modifier(SwipeAction.init(content)) + func addSwipeAction(menu: MenuType = .slided, @ViewBuilder _ content: @escaping () -> TupleView<(Leading, Trailing)>) -> some View { + self.modifier(SwipeAction.init(menu: menu, content)) } @ViewBuilder - func addSwipeAction(edge: HorizontalAlignment, @ViewBuilder _ content: @escaping () -> V1) -> some View { + func addSwipeAction(menu: MenuType = .slided, edge: HorizontalAlignment, @ViewBuilder _ content: @escaping () -> V1) -> some View { switch edge { case .leading: - self.modifier(SwipeAction.init(leading: content)) + self.modifier(SwipeAction.init(menu: menu, leading: content)) default: - self.modifier(SwipeAction.init(trailing: content)) + self.modifier(SwipeAction.init(menu: menu, trailing: content)) } } } diff --git a/Sources/SwipeActions/ViewModifiers/ValueChangedModifier.swift b/Sources/SwipeActions/ViewModifiers/ValueChangedModifier.swift new file mode 100644 index 0000000..da34844 --- /dev/null +++ b/Sources/SwipeActions/ViewModifiers/ValueChangedModifier.swift @@ -0,0 +1,22 @@ +// +// ValueChangedModifier.swift +// +// +// Created by c-villain on 28.12.2021. +// + +import SwiftUI +import Combine + +extension View { + /// A backwards compatible wrapper for iOS 14 `onChange` + @ViewBuilder func valueChanged(of value: T, onChange: @escaping (T) -> Void) -> some View { + if #available(iOS 14.0, *) { + self.onChange(of: value, perform: onChange) + } else { + self.onReceive(Just(value)) { (value) in + onChange(value) + } + } + } +}