Files
GitHawk/SwipeCellKit/Source/SwipeCollectionViewCell.swift
2017-07-01 23:01:47 -04:00

516 lines
17 KiB
Swift

//
// SwipeCollectionViewCell.swift
// SwipeCellKit
//
// Created by Ryan Nystrom on 6/26/17.
//
//
import UIKit
open class SwipeCollectionViewCell: UICollectionViewCell {
public weak var delegate: SwipeCollectionViewCellDelegate?
public var canDelete = false
var animator: SwipeAnimator?
var deleting = false
var state = SwipeState.center
var originalCenter: CGFloat = 0
weak var collectionView: UICollectionView?
var actionsView: SwipeActionsView?
var originalLayoutMargins: UIEdgeInsets = .zero
lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(gesture:)))
gesture.delegate = self
return gesture
}()
lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(gesture:)))
gesture.delegate = self
return gesture
}()
let elasticScrollRatio: CGFloat = 0.4
var scrollRatio: CGFloat = 1.0
/// :nodoc:
override open var center: CGPoint {
set {
if !deleting {
super.center = newValue
}
actionsView?.visibleWidth = abs(frame.minX)
}
get {
return super.center
}
}
/// :nodoc:
open override var frame: CGRect {
set { super.frame = state.isActive ? CGRect(origin: CGPoint(x: frame.minX, y: newValue.minY), size: newValue.size) : newValue }
get { return super.frame }
}
/// :nodoc:
public override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
/// :nodoc:
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
deinit {
collectionView?.panGestureRecognizer.removeTarget(self, action: nil)
}
func configure() {
clipsToBounds = false
addGestureRecognizer(tapGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
}
/// :nodoc:
override open func prepareForReuse() {
super.prepareForReuse()
reset()
}
/// :nodoc:
override open func didMoveToSuperview() {
super.didMoveToSuperview()
var view: UIView = self
while let superview = view.superview {
view = superview
if let collectionView = view as? UICollectionView {
self.collectionView = collectionView
collectionView.panGestureRecognizer.removeTarget(self, action: nil)
collectionView.panGestureRecognizer.addTarget(self, action: #selector(handleTablePan(gesture:)))
return
}
}
}
func handlePan(gesture: UIPanGestureRecognizer) {
guard let target = gesture.view else { return }
switch gesture.state {
case .began:
stopAnimatorIfNeeded()
originalCenter = center.x
if state == .center || state == .animatingToCenter {
let velocity = gesture.velocity(in: target)
let orientation: SwipeActionsOrientation = velocity.x > 0 ? .left : .right
showActionsView(for: orientation)
}
case .changed:
guard let actionsView = actionsView else { return }
let translation = gesture.translation(in: target).x
scrollRatio = 1.0
// Check if dragging past the center of the opposite direction of action view, if so
// then we need to apply elasticity
if (translation + originalCenter - bounds.midX) * actionsView.orientation.scale > 0 {
target.center.x = gesture.elasticTranslation(in: target,
withLimit: .zero,
fromOriginalCenter: CGPoint(x: originalCenter, y: 0)).x
scrollRatio = elasticScrollRatio
return
}
if let expansionStyle = actionsView.options.expansionStyle {
let expanded = expansionStyle.shouldExpand(view: self, gesture: gesture, in: collectionView!)
let targetOffset = expansionStyle.targetOffset(for: self, in: collectionView!)
let currentOffset = abs(translation + originalCenter - bounds.midX)
if expanded && !actionsView.expanded && targetOffset > currentOffset {
let centerForTranslationToEdge = bounds.midX - targetOffset * actionsView.orientation.scale
let delta = centerForTranslationToEdge - originalCenter
animate(toOffset: centerForTranslationToEdge)
gesture.setTranslation(CGPoint(x: delta, y: 0), in: superview!)
} else {
target.center.x = gesture.elasticTranslation(in: target,
withLimit: CGSize(width: targetOffset, height: 0),
fromOriginalCenter: CGPoint(x: originalCenter, y: 0),
applyingRatio: expansionStyle.targetOverscrollElasticity).x
}
actionsView.setExpanded(expanded: expanded, feedback: true)
} else {
target.center.x = gesture.elasticTranslation(in: target,
withLimit: CGSize(width: actionsView.preferredWidth, height: 0),
fromOriginalCenter: CGPoint(x: originalCenter, y: 0),
applyingRatio: elasticScrollRatio).x
if (target.center.x - originalCenter) / translation != 1.0 {
scrollRatio = elasticScrollRatio
}
}
case .ended:
guard let actionsView = actionsView else { return }
let velocity = gesture.velocity(in: target)
state = targetState(forVelocity: velocity)
if actionsView.expanded == true, let expandedAction = actionsView.expandableAction {
perform(action: expandedAction)
} else {
let targetOffset = targetCenter(active: state.isActive)
let distance = targetOffset - center.x
let normalizedVelocity = velocity.x * scrollRatio / distance
animate(toOffset: targetOffset, withInitialVelocity: normalizedVelocity) { _ in
if self.state == .center {
self.reset()
}
}
if !state.isActive {
notifyEditingStateChange(active: false)
}
}
default: break
}
}
@discardableResult
func showActionsView(for orientation: SwipeActionsOrientation) -> Bool {
guard let collectionView = collectionView,
let indexPath = collectionView.indexPath(for: self),
let actions = delegate?.collectionView(collectionView, editActionsForRowAt: indexPath, for: orientation),
actions.count > 0
else {
return false
}
originalLayoutMargins = super.layoutMargins
// Remove highlight and deselect any selected cells
isHighlighted = false
let selectedIndexPaths = collectionView.indexPathsForSelectedItems
selectedIndexPaths?.forEach { collectionView.deselectItem(at: $0, animated: false) }
// Temporarily remove table gestures
collectionView.setGestureEnabled(false)
configureActionsView(with: actions, for: orientation)
return true
}
func configureActionsView(with actions: [SwipeAction], for orientation: SwipeActionsOrientation) {
guard let collectionView = collectionView,
let indexPath = collectionView.indexPath(for: self) else { return }
let options = delegate?.collectionView(collectionView, editActionsOptionsForRowAt: indexPath, for: orientation) ?? SwipeTableOptions()
self.actionsView?.removeFromSuperview()
self.actionsView = nil
let actionsView = SwipeActionsView(maxSize: bounds.size,
options: options,
orientation: orientation,
actions: actions)
actionsView.delegate = self
addSubview(actionsView)
actionsView.heightAnchor.constraint(equalTo: heightAnchor).isActive = true
actionsView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 2).isActive = true
actionsView.topAnchor.constraint(equalTo: topAnchor).isActive = true
if orientation == .left {
actionsView.rightAnchor.constraint(equalTo: leftAnchor).isActive = true
} else {
actionsView.leftAnchor.constraint(equalTo: rightAnchor).isActive = true
}
self.actionsView = actionsView
state = .dragging
notifyEditingStateChange(active: true)
}
func notifyEditingStateChange(active: Bool) {
guard let actionsView = actionsView,
let collectionView = collectionView,
let indexPath = collectionView.indexPath(for: self) else { return }
if active {
delegate?.collectionView(collectionView, willBeginEditingRowAt: indexPath, for: actionsView.orientation)
} else {
delegate?.collectionView(collectionView, didEndEditingRowAt: indexPath, for: actionsView.orientation)
}
}
func animate(duration: Double = 0.7, toOffset offset: CGFloat, withInitialVelocity velocity: CGFloat = 0, completion: ((Bool) -> Void)? = nil) {
stopAnimatorIfNeeded()
layoutIfNeeded()
let animator: SwipeAnimator = {
if velocity != 0 {
if #available(iOS 10, *) {
let velocity = CGVector(dx: velocity, dy: velocity)
let parameters = UISpringTimingParameters(mass: 1.0, stiffness: 100, damping: 18, initialVelocity: velocity)
return UIViewPropertyAnimator(duration: 0.0, timingParameters: parameters)
} else {
return UIViewSpringAnimator(duration: duration, damping: 1.0, initialVelocity: velocity)
}
} else {
if #available(iOS 10, *) {
return UIViewPropertyAnimator(duration: duration, dampingRatio: 1.0)
} else {
return UIViewSpringAnimator(duration: duration, damping: 1.0)
}
}
}()
animator.addAnimations({
self.center = CGPoint(x: offset, y: self.center.y)
self.layoutIfNeeded()
})
if let completion = completion {
animator.addCompletion(completion: completion)
}
self.animator = animator
animator.startAnimation()
}
func stopAnimatorIfNeeded() {
if animator?.isRunning == true {
animator?.stopAnimation(true)
}
}
func handleTap(gesture: UITapGestureRecognizer) {
hideSwipe(animated: true)
}
func handleTablePan(gesture: UIPanGestureRecognizer) {
if gesture.state == .began {
hideSwipe(animated: true)
}
}
// Override so we can accept touches anywhere within the cell's minY/maxY.
// This is required to detect touches on the `SwipeActionsView` sitting alongside the
// `SwipeTableCell`.
/// :nodoc:
override open func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
guard let superview = superview else { return false }
let point = convert(point, to: superview)
if !UIAccessibilityIsVoiceOverRunning() {
for cell in collectionView?.swipeCells ?? [] {
if (cell.state == .left || cell.state == .right) && !cell.contains(point: point) {
collectionView?.hideSwipeCell()
return false
}
}
}
return contains(point: point)
}
func contains(point: CGPoint) -> Bool {
return point.y > frame.minY && point.y < frame.maxY
}
/// :nodoc:
open override var isHighlighted: Bool {
didSet {
super.isHighlighted = isHighlighted && state == .center
}
}
/// :nodoc:
override open var layoutMargins: UIEdgeInsets {
get {
return frame.origin.x != 0 ? originalLayoutMargins : super.layoutMargins
}
set {
super.layoutMargins = newValue
}
}
}
extension SwipeCollectionViewCell {
func targetState(forVelocity velocity: CGPoint) -> SwipeState {
guard let actionsView = actionsView else { return .center }
switch actionsView.orientation {
case .left:
return (velocity.x < 0 && !actionsView.expanded) ? .center : .left
case .right:
return (velocity.x > 0 && !actionsView.expanded) ? .center : .right
}
}
func targetCenter(active: Bool) -> CGFloat {
guard let actionsView = actionsView, active == true else { return bounds.midX }
return bounds.midX - actionsView.preferredWidth * actionsView.orientation.scale
}
func reset() {
deleting = false
state = .center
collectionView?.setGestureEnabled(true)
actionsView?.removeFromSuperview()
actionsView = nil
}
}
extension SwipeCollectionViewCell: SwipeActionsViewDelegate {
func swipeActionsView(_ swipeActionsView: SwipeActionsView, didSelect action: SwipeAction) {
perform(action: action)
}
func perform(action: SwipeAction) {
guard let actionsView = actionsView else { return }
if action == actionsView.expandableAction, let expansionStyle = actionsView.options.expansionStyle {
// Trigger the expansion (may already be expanded from drag)
actionsView.setExpanded(expanded: true)
switch expansionStyle.completionAnimation {
case .bounce:
perform(action: action, hide: true)
case .fill(let fillOption):
performFillAction(action: action, fillOption: fillOption)
}
} else {
perform(action: action, hide: action.hidesWhenSelected)
}
}
func perform(action: SwipeAction, hide: Bool) {
guard let collectionView = collectionView, let indexPath = collectionView.indexPath(for: self) else { return }
if hide {
hideSwipe(animated: true)
}
action.handler?(action, indexPath)
}
func performFillAction(action: SwipeAction, fillOption: SwipeExpansionStyle.FillOptions) {
guard let actionsView = actionsView,
let collectionView = collectionView,
let indexPath = collectionView.indexPath(for: self) else { return }
let newCenter = bounds.midX - (bounds.width + actionsView.minimumButtonWidth) * actionsView.orientation.scale
action.completionHandler = { [weak self] style in
action.completionHandler = nil
self?.delegate?.collectionView(collectionView, didEndEditingRowAt: indexPath, for: actionsView.orientation)
switch style {
case .delete:
self?.mask = actionsView.createDeletionMask()
if self?.canDelete == true {
collectionView.deleteItems(at: [indexPath])
}
UIView.animate(withDuration: 0.3, animations: {
self?.center.x = newCenter
self?.mask?.frame.size.height = 0
if fillOption.timing == .after {
actionsView.alpha = 0
}
}) { [weak self] _ in
self?.mask = nil
self?.reset()
}
case .reset:
self?.hideSwipe(animated: true)
}
}
let invokeAction = {
action.handler?(action, indexPath)
if let style = fillOption.autoFulFillmentStyle {
action.fulfill(with: style)
}
}
animate(duration: 0.3, toOffset: newCenter) { _ in
if fillOption.timing == .after {
self.deleting = true
invokeAction()
}
}
if fillOption.timing == .with {
self.deleting = true
invokeAction()
}
}
}
extension SwipeCollectionViewCell: UIGestureRecognizerDelegate {
override open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == tapGestureRecognizer {
if UIAccessibilityIsVoiceOverRunning() {
collectionView?.hideSwipeCell()
}
let cell = collectionView?.swipeCells.first(where: { $0.state.isActive })
return cell == nil ? false : true
}
if gestureRecognizer == panGestureRecognizer,
let view = gestureRecognizer.view,
let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer
{
let translation = gestureRecognizer.translation(in: view)
return abs(translation.y) <= abs(translation.x)
}
return true
}
}
extension SwipeCollectionViewCell: Swipeable {}