SwipeActionsView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. //
  2. // SwipeActionsView.swift
  3. //
  4. // Created by Jeremy Koch
  5. // Copyright © 2017 Jeremy Koch. All rights reserved.
  6. //
  7. import UIKit
  8. protocol SwipeActionsViewDelegate: class {
  9. func swipeActionsView(_ swipeActionsView: SwipeActionsView, didSelect action: SwipeAction)
  10. }
  11. class SwipeActionsView: UIView {
  12. weak var delegate: SwipeActionsViewDelegate?
  13. let transitionLayout: SwipeTransitionLayout
  14. var layoutContext: ActionsViewLayoutContext
  15. var feedbackGenerator: SwipeFeedback
  16. var expansionAnimator: SwipeAnimator?
  17. var expansionDelegate: SwipeExpanding? {
  18. return options.expansionDelegate ?? (expandableAction?.hasBackgroundColor == false ? ScaleAndAlphaExpansion.default : nil)
  19. }
  20. weak var safeAreaInsetView: UIView?
  21. let orientation: SwipeActionsOrientation
  22. let actions: [SwipeAction]
  23. let options: SwipeOptions
  24. var buttons: [SwipeActionButton] = []
  25. var minimumButtonWidth: CGFloat = 0
  26. var maximumImageHeight: CGFloat {
  27. return actions.reduce(0, { initial, next in max(initial, next.image?.size.height ?? 0) })
  28. }
  29. var safeAreaMargin: CGFloat {
  30. guard #available(iOS 11, *) else { return 0 }
  31. guard let scrollView = self.safeAreaInsetView else { return 0 }
  32. return orientation == .left ? scrollView.safeAreaInsets.left : scrollView.safeAreaInsets.right
  33. }
  34. var visibleWidth: CGFloat = 0 {
  35. didSet {
  36. // If necessary, adjust for safe areas
  37. visibleWidth = max(0, visibleWidth - safeAreaMargin)
  38. let preLayoutVisibleWidths = transitionLayout.visibleWidthsForViews(with: layoutContext)
  39. layoutContext = ActionsViewLayoutContext.newContext(for: self)
  40. transitionLayout.container(view: self, didChangeVisibleWidthWithContext: layoutContext)
  41. setNeedsLayout()
  42. layoutIfNeeded()
  43. notifyVisibleWidthChanged(oldWidths: preLayoutVisibleWidths,
  44. newWidths: transitionLayout.visibleWidthsForViews(with: layoutContext))
  45. }
  46. }
  47. var preferredWidth: CGFloat {
  48. return minimumButtonWidth * CGFloat(actions.count) + safeAreaMargin
  49. }
  50. var contentSize: CGSize {
  51. if options.expansionStyle?.elasticOverscroll != true || visibleWidth < preferredWidth {
  52. return CGSize(width: visibleWidth, height: bounds.height)
  53. } else {
  54. let scrollRatio = max(0, visibleWidth - preferredWidth)
  55. return CGSize(width: preferredWidth + (scrollRatio * 0.25), height: bounds.height)
  56. }
  57. }
  58. private(set) var expanded: Bool = false
  59. var expandableAction: SwipeAction? {
  60. return options.expansionStyle != nil ? actions.last : nil
  61. }
  62. init(contentEdgeInsets: UIEdgeInsets,
  63. maxSize: CGSize,
  64. safeAreaInsetView: UIView,
  65. options: SwipeOptions,
  66. orientation: SwipeActionsOrientation,
  67. actions: [SwipeAction]) {
  68. self.safeAreaInsetView = safeAreaInsetView
  69. self.options = options
  70. self.orientation = orientation
  71. self.actions = actions.reversed()
  72. switch options.transitionStyle {
  73. case .border:
  74. transitionLayout = BorderTransitionLayout()
  75. case .reveal:
  76. transitionLayout = RevealTransitionLayout()
  77. default:
  78. transitionLayout = DragTransitionLayout()
  79. }
  80. self.layoutContext = ActionsViewLayoutContext(numberOfActions: actions.count, orientation: orientation)
  81. feedbackGenerator = SwipeFeedback(style: .light)
  82. feedbackGenerator.prepare()
  83. super.init(frame: .zero)
  84. clipsToBounds = true
  85. translatesAutoresizingMaskIntoConstraints = false
  86. #if canImport(Combine)
  87. if let backgroundColor = options.backgroundColor {
  88. self.backgroundColor = backgroundColor
  89. }
  90. else if #available(iOS 13.0, *) {
  91. backgroundColor = UIColor.systemGray5
  92. } else {
  93. backgroundColor = #colorLiteral(red: 0.7803494334, green: 0.7761332393, blue: 0.7967314124, alpha: 1)
  94. }
  95. #else
  96. if let backgroundColor = options.backgroundColor {
  97. self.backgroundColor = backgroundColor
  98. }
  99. else {
  100. backgroundColor = #colorLiteral(red: 0.7803494334, green: 0.7761332393, blue: 0.7967314124, alpha: 1)
  101. }
  102. #endif
  103. buttons = addButtons(for: self.actions, withMaximum: maxSize, contentEdgeInsets: contentEdgeInsets)
  104. }
  105. required init?(coder aDecoder: NSCoder) {
  106. fatalError("init(coder:) has not been implemented")
  107. }
  108. func addButtons(for actions: [SwipeAction], withMaximum size: CGSize, contentEdgeInsets: UIEdgeInsets) -> [SwipeActionButton] {
  109. let buttons: [SwipeActionButton] = actions.map({ action in
  110. let actionButton = SwipeActionButton(action: action)
  111. actionButton.addTarget(self, action: #selector(actionTapped(button:)), for: .touchUpInside)
  112. actionButton.autoresizingMask = [.flexibleHeight, orientation == .right ? .flexibleRightMargin : .flexibleLeftMargin]
  113. actionButton.spacing = options.buttonSpacing ?? 8
  114. actionButton.contentEdgeInsets = buttonEdgeInsets(fromOptions: options)
  115. return actionButton
  116. })
  117. let maximum = options.maximumButtonWidth ?? (size.width - 30) / CGFloat(actions.count)
  118. let minimum = options.minimumButtonWidth ?? min(maximum, 74)
  119. minimumButtonWidth = buttons.reduce(minimum, { initial, next in max(initial, next.preferredWidth(maximum: maximum)) })
  120. buttons.enumerated().forEach { (index, button) in
  121. let action = actions[index]
  122. let frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height))
  123. let wrapperView = SwipeActionButtonWrapperView(frame: frame, action: action, orientation: orientation, contentWidth: minimumButtonWidth)
  124. wrapperView.translatesAutoresizingMaskIntoConstraints = false
  125. wrapperView.addSubview(button)
  126. if let effect = action.backgroundEffect {
  127. let effectView = UIVisualEffectView(effect: effect)
  128. effectView.frame = wrapperView.frame
  129. effectView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
  130. effectView.contentView.addSubview(wrapperView)
  131. addSubview(effectView)
  132. } else {
  133. addSubview(wrapperView)
  134. }
  135. button.frame = wrapperView.contentRect
  136. button.maximumImageHeight = maximumImageHeight
  137. button.verticalAlignment = options.buttonVerticalAlignment
  138. button.shouldHighlight = action.hasBackgroundColor
  139. wrapperView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
  140. wrapperView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
  141. let topConstraint = wrapperView.topAnchor.constraint(equalTo: topAnchor, constant: contentEdgeInsets.top)
  142. topConstraint.priority = contentEdgeInsets.top == 0 ? .required : .defaultHigh
  143. topConstraint.isActive = true
  144. let bottomConstraint = wrapperView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1 * contentEdgeInsets.bottom)
  145. bottomConstraint.priority = contentEdgeInsets.bottom == 0 ? .required : .defaultHigh
  146. bottomConstraint.isActive = true
  147. if contentEdgeInsets != .zero {
  148. let heightConstraint = wrapperView.heightAnchor.constraint(greaterThanOrEqualToConstant: button.intrinsicContentSize.height)
  149. heightConstraint.priority = .required
  150. heightConstraint.isActive = true
  151. }
  152. }
  153. return buttons
  154. }
  155. @objc func actionTapped(button: SwipeActionButton) {
  156. guard let index = buttons.firstIndex(of: button) else { return }
  157. delegate?.swipeActionsView(self, didSelect: actions[index])
  158. }
  159. func buttonEdgeInsets(fromOptions options: SwipeOptions) -> UIEdgeInsets {
  160. let padding = options.buttonPadding ?? 8
  161. return UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding)
  162. }
  163. func setExpanded(expanded: Bool, feedback: Bool = false) {
  164. guard self.expanded != expanded else { return }
  165. self.expanded = expanded
  166. if feedback {
  167. feedbackGenerator.impactOccurred()
  168. feedbackGenerator.prepare()
  169. }
  170. let timingParameters = expansionDelegate?.animationTimingParameters(buttons: buttons.reversed(), expanding: expanded)
  171. if expansionAnimator?.isRunning == true {
  172. expansionAnimator?.stopAnimation(true)
  173. }
  174. if #available(iOS 10, *) {
  175. expansionAnimator = UIViewPropertyAnimator(duration: timingParameters?.duration ?? 0.6, dampingRatio: 1.0)
  176. } else {
  177. expansionAnimator = UIViewSpringAnimator(duration: timingParameters?.duration ?? 0.6,
  178. damping: 1.0,
  179. initialVelocity: 1.0)
  180. }
  181. expansionAnimator?.addAnimations {
  182. self.setNeedsLayout()
  183. self.layoutIfNeeded()
  184. }
  185. expansionAnimator?.startAnimation(afterDelay: timingParameters?.delay ?? 0)
  186. notifyExpansion(expanded: expanded)
  187. }
  188. func notifyVisibleWidthChanged(oldWidths: [CGFloat], newWidths: [CGFloat]) {
  189. DispatchQueue.main.async {
  190. oldWidths.enumerated().forEach { index, oldWidth in
  191. let newWidth = newWidths[index]
  192. if oldWidth != newWidth {
  193. let context = SwipeActionTransitioningContext(actionIdentifier: self.actions[index].identifier,
  194. button: self.buttons[index],
  195. newPercentVisible: newWidth / self.minimumButtonWidth,
  196. oldPercentVisible: oldWidth / self.minimumButtonWidth,
  197. wrapperView: self.subviews[index])
  198. self.actions[index].transitionDelegate?.didTransition(with: context)
  199. }
  200. }
  201. }
  202. }
  203. func notifyExpansion(expanded: Bool) {
  204. guard let expandedButton = buttons.last else { return }
  205. expansionDelegate?.actionButton(expandedButton, didChange: expanded, otherActionButtons: buttons.dropLast().reversed())
  206. }
  207. func createDeletionMask() -> UIView {
  208. let mask = UIView(frame: CGRect(x: min(0, frame.minX), y: 0, width: bounds.width * 2, height: bounds.height))
  209. mask.backgroundColor = UIColor.white
  210. return mask
  211. }
  212. override func layoutSubviews() {
  213. super.layoutSubviews()
  214. for subview in subviews.enumerated() {
  215. transitionLayout.layout(view: subview.element, atIndex: subview.offset, with: layoutContext)
  216. }
  217. if expanded {
  218. subviews.last?.frame.origin.x = 0 + bounds.origin.x
  219. }
  220. }
  221. }
  222. class SwipeActionButtonWrapperView: UIView {
  223. let contentRect: CGRect
  224. var actionBackgroundColor: UIColor?
  225. init(frame: CGRect, action: SwipeAction, orientation: SwipeActionsOrientation, contentWidth: CGFloat) {
  226. switch orientation {
  227. case .left:
  228. contentRect = CGRect(x: frame.width - contentWidth, y: 0, width: contentWidth, height: frame.height)
  229. case .right:
  230. contentRect = CGRect(x: 0, y: 0, width: contentWidth, height: frame.height)
  231. }
  232. super.init(frame: frame)
  233. configureBackgroundColor(with: action)
  234. }
  235. override func draw(_ rect: CGRect) {
  236. super.draw(rect)
  237. if let actionBackgroundColor = self.actionBackgroundColor, let context = UIGraphicsGetCurrentContext() {
  238. actionBackgroundColor.setFill()
  239. context.fill(rect);
  240. }
  241. }
  242. func configureBackgroundColor(with action: SwipeAction) {
  243. guard action.hasBackgroundColor else {
  244. isOpaque = false
  245. return
  246. }
  247. if let backgroundColor = action.backgroundColor {
  248. actionBackgroundColor = backgroundColor
  249. } else {
  250. switch action.style {
  251. case .destructive:
  252. #if canImport(Combine)
  253. if #available(iOS 13.0, *) {
  254. actionBackgroundColor = UIColor.systemRed
  255. } else {
  256. actionBackgroundColor = #colorLiteral(red: 1, green: 0.2352941176, blue: 0.1882352941, alpha: 1)
  257. }
  258. #else
  259. actionBackgroundColor = #colorLiteral(red: 1, green: 0.2352941176, blue: 0.1882352941, alpha: 1)
  260. #endif
  261. default:
  262. #if canImport(Combine)
  263. if #available(iOS 13.0, *) {
  264. actionBackgroundColor = UIColor.systemGray3
  265. } else {
  266. actionBackgroundColor = #colorLiteral(red: 0.7803494334, green: 0.7761332393, blue: 0.7967314124, alpha: 1)
  267. }
  268. #else
  269. actionBackgroundColor = #colorLiteral(red: 0.7803494334, green: 0.7761332393, blue: 0.7967314124, alpha: 1)
  270. #endif
  271. }
  272. }
  273. }
  274. required init?(coder aDecoder: NSCoder) {
  275. fatalError("init(coder:) has not been implemented")
  276. }
  277. }