SwipeExpansionStyle.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. //
  2. // SwipeExpansionStyle.swift
  3. //
  4. // Created by Jeremy Koch
  5. // Copyright © 2017 Jeremy Koch. All rights reserved.
  6. //
  7. import UIKit
  8. /// Describes the expansion style. Expansion is the behavior when the cell is swiped past a defined threshold.
  9. public struct SwipeExpansionStyle {
  10. /// The default action performs a selection-type behavior. The cell bounces back to its unopened state upon selection and the row remains in the table/collection view.
  11. public static var selection: SwipeExpansionStyle { return SwipeExpansionStyle(target: .percentage(0.5),
  12. elasticOverscroll: true,
  13. completionAnimation: .bounce) }
  14. /// The default action performs a destructive behavior. The cell is removed from the table/collection view in an animated fashion.
  15. public static var destructive: SwipeExpansionStyle { return .destructive(automaticallyDelete: true, timing: .with) }
  16. /// The default action performs a destructive behavior after the fill animation completes. The cell is removed from the table/collection view in an animated fashion.
  17. public static var destructiveAfterFill: SwipeExpansionStyle { return .destructive(automaticallyDelete: true, timing: .after) }
  18. /// The default action performs a fill behavior.
  19. ///
  20. /// - note: The action handle must call `SwipeAction.fulfill(style:)` to resolve the fill expansion.
  21. public static var fill: SwipeExpansionStyle { return SwipeExpansionStyle(target: .edgeInset(30),
  22. additionalTriggers: [.overscroll(30)],
  23. completionAnimation: .fill(.manual(timing: .after))) }
  24. /**
  25. Returns a `SwipeExpansionStyle` instance for the default action which peforms destructive behavior with the specified options.
  26. - parameter automaticallyDelete: Specifies if row/item deletion should be peformed automatically. If `false`, you must call `SwipeAction.fulfill(with style:)` at some point while/after your action handler is invoked to trigger deletion.
  27. - parameter timing: The timing which specifies when the action handler will be invoked with respect to the fill animation.
  28. - returns: The new `SwipeExpansionStyle` instance.
  29. */
  30. public static func destructive(automaticallyDelete: Bool, timing: FillOptions.HandlerInvocationTiming = .with) -> SwipeExpansionStyle {
  31. return SwipeExpansionStyle(target: .edgeInset(30),
  32. additionalTriggers: [.touchThreshold(0.8)],
  33. completionAnimation: .fill(automaticallyDelete ? .automatic(.delete, timing: timing) : .manual(timing: timing)))
  34. }
  35. /// The relative target expansion threshold. Expansion will occur at the specified value.
  36. public let target: Target
  37. /// Additional triggers to useful for determining if expansion should occur.
  38. public let additionalTriggers: [Trigger]
  39. /// Specifies if buttons should expand to fully fill overscroll, or expand at a percentage relative to the overscroll.
  40. public let elasticOverscroll: Bool
  41. /// Specifies the expansion animation completion style.
  42. public let completionAnimation: CompletionAnimation
  43. /// Specifies the minimum amount of overscroll required if the configured target is less than the fully exposed action view.
  44. public var minimumTargetOverscroll: CGFloat = 20
  45. /// The amount of elasticity applied when dragging past the expansion target.
  46. ///
  47. /// - note: Default value is 0.2. Valid range is from 0.0 for no movement past the expansion target, to 1.0 for unrestricted movement with dragging.
  48. public var targetOverscrollElasticity: CGFloat = 0.2
  49. var minimumExpansionTranslation: CGFloat = 8.0
  50. /**
  51. Contructs a new `SwipeExpansionStyle` instance.
  52. - parameter target: The relative target expansion threshold. Expansion will occur at the specified value.
  53. - parameter additionalTriggers: Additional triggers to useful for determining if expansion should occur.
  54. - parameter elasticOverscroll: Specifies if buttons should expand to fully fill overscroll, or expand at a percentage relative to the overscroll.
  55. - parameter completionAnimation: Specifies the expansion animation completion style.
  56. - returns: The new `SwipeExpansionStyle` instance.
  57. */
  58. public init(target: Target, additionalTriggers: [Trigger] = [], elasticOverscroll: Bool = false, completionAnimation: CompletionAnimation = .bounce) {
  59. self.target = target
  60. self.additionalTriggers = additionalTriggers
  61. self.elasticOverscroll = elasticOverscroll
  62. self.completionAnimation = completionAnimation
  63. }
  64. func shouldExpand(view: Swipeable, gesture: UIPanGestureRecognizer, in superview: UIView, within frame: CGRect? = nil) -> Bool {
  65. guard let actionsView = view.actionsView, let gestureView = gesture.view else { return false }
  66. guard abs(gesture.translation(in: gestureView).x) > minimumExpansionTranslation else { return false }
  67. let xDelta = floor(abs(frame?.minX ?? view.frame.minX))
  68. if xDelta <= actionsView.preferredWidth {
  69. return false
  70. } else if xDelta > targetOffset(for: view) {
  71. return true
  72. }
  73. // Use the frame instead of superview as Swipeable may not be full width of superview
  74. let referenceFrame: CGRect = frame != nil ? view.frame : superview.bounds
  75. for trigger in additionalTriggers {
  76. if trigger.isTriggered(view: view, gesture: gesture, in: superview, referenceFrame: referenceFrame) {
  77. return true
  78. }
  79. }
  80. return false
  81. }
  82. func targetOffset(for view: Swipeable) -> CGFloat {
  83. return target.offset(for: view, minimumOverscroll: minimumTargetOverscroll)
  84. }
  85. }
  86. extension SwipeExpansionStyle {
  87. /// Describes the relative target expansion threshold. Expansion will occur at the specified value.
  88. public enum Target {
  89. /// The target is specified by a percentage.
  90. case percentage(CGFloat)
  91. /// The target is specified by a edge inset.
  92. case edgeInset(CGFloat)
  93. func offset(for view: Swipeable, minimumOverscroll: CGFloat) -> CGFloat {
  94. guard let actionsView = view.actionsView else { return .greatestFiniteMagnitude }
  95. let offset: CGFloat = {
  96. switch self {
  97. case .percentage(let value):
  98. return view.frame.width * value
  99. case .edgeInset(let value):
  100. return view.frame.width - value
  101. }
  102. }()
  103. return max(actionsView.preferredWidth + minimumOverscroll, offset)
  104. }
  105. }
  106. /// Describes additional triggers to useful for determining if expansion should occur.
  107. public enum Trigger {
  108. /// The trigger is specified by a touch occuring past the supplied percentage in the superview.
  109. case touchThreshold(CGFloat)
  110. /// The trigger is specified by the distance in points past the fully exposed action view.
  111. case overscroll(CGFloat)
  112. func isTriggered(view: Swipeable, gesture: UIPanGestureRecognizer, in superview: UIView, referenceFrame: CGRect) -> Bool {
  113. guard let actionsView = view.actionsView else { return false }
  114. switch self {
  115. case .touchThreshold(let value):
  116. let location = gesture.location(in: superview).x - referenceFrame.origin.x
  117. let locationRatio = (actionsView.orientation == .left ? location : referenceFrame.width - location) / referenceFrame.width
  118. return locationRatio > value
  119. case .overscroll(let value):
  120. return abs(view.frame.minX) > actionsView.preferredWidth + value
  121. }
  122. }
  123. }
  124. /// Describes the expansion animation completion style.
  125. public enum CompletionAnimation {
  126. /// The expansion will completely fill the item.
  127. case fill(FillOptions)
  128. /// The expansion will bounce back from the trigger point and hide the action view, resetting the item.
  129. case bounce
  130. }
  131. /// Specifies the options for the fill completion animation.
  132. public struct FillOptions {
  133. /// Describes when the action handler will be invoked with respect to the fill animation.
  134. public enum HandlerInvocationTiming {
  135. /// The action handler is invoked with the fill animation.
  136. case with
  137. /// The action handler is invoked after the fill animation completes.
  138. case after
  139. }
  140. /**
  141. Returns a `FillOptions` instance with automatic fulfillemnt.
  142. - parameter style: The fulfillment style describing how expansion should be resolved once the action has been fulfilled.
  143. - parameter timing: The timing which specifies when the action handler will be invoked with respect to the fill animation.
  144. - returns: The new `FillOptions` instance.
  145. */
  146. public static func automatic(_ style: ExpansionFulfillmentStyle, timing: HandlerInvocationTiming) -> FillOptions {
  147. return FillOptions(autoFulFillmentStyle: style, timing: timing)
  148. }
  149. /**
  150. Returns a `FillOptions` instance with manual fulfillemnt.
  151. - parameter timing: The timing which specifies when the action handler will be invoked with respect to the fill animation.
  152. - returns: The new `FillOptions` instance.
  153. */
  154. public static func manual(timing: HandlerInvocationTiming) -> FillOptions {
  155. return FillOptions(autoFulFillmentStyle: nil, timing: timing)
  156. }
  157. /// The fulfillment style describing how expansion should be resolved once the action has been fulfilled.
  158. public let autoFulFillmentStyle: ExpansionFulfillmentStyle?
  159. /// The timing which specifies when the action handler will be invoked with respect to the fill animation.
  160. public let timing: HandlerInvocationTiming
  161. }
  162. }
  163. extension SwipeExpansionStyle.Target: Equatable {
  164. /// :nodoc:
  165. public static func ==(lhs: SwipeExpansionStyle.Target, rhs: SwipeExpansionStyle.Target) -> Bool {
  166. switch (lhs, rhs) {
  167. case (.percentage(let lhs), .percentage(let rhs)):
  168. return lhs == rhs
  169. case (.edgeInset(let lhs), .edgeInset(let rhs)):
  170. return lhs == rhs
  171. default:
  172. return false
  173. }
  174. }
  175. }
  176. extension SwipeExpansionStyle.CompletionAnimation: Equatable {
  177. /// :nodoc:
  178. public static func ==(lhs: SwipeExpansionStyle.CompletionAnimation, rhs: SwipeExpansionStyle.CompletionAnimation) -> Bool {
  179. switch (lhs, rhs) {
  180. case (.fill, .fill):
  181. return true
  182. case (.bounce, .bounce):
  183. return true
  184. default:
  185. return false
  186. }
  187. }
  188. }