deep-linking-sample/Apps/Wikipedia/WMF Framework/CollectionViewEditController.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

653 lines
25 KiB
Swift

import Foundation
public enum CollectionViewCellSwipeType {
case primary, secondary, none
}
enum CollectionViewCellState {
case idle, open
}
// wrapper around UIBarButtonItem that lets us access systemItem after button creation
public class SystemBarButton: UIBarButtonItem {
var systemItem: UIBarButtonItem.SystemItem?
public convenience init(with barButtonSystemItem: UIBarButtonItem.SystemItem, target: Any?, action: Selector?) {
self.init(barButtonSystemItem: barButtonSystemItem, target: target, action: action)
self.systemItem = barButtonSystemItem
}
}
public protocol CollectionViewEditControllerNavigationDelegate: AnyObject {
func didChangeEditingState(from oldEditingState: EditingState, to newEditingState: EditingState, rightBarButton: UIBarButtonItem?, leftBarButton: UIBarButtonItem?) // same implementation for 2/3
func didSetBatchEditToolbarHidden(_ batchEditToolbarViewController: BatchEditToolbarViewController, isHidden: Bool, with items: [UIButton]) // has default implementation
func newEditingState(for currentEditingState: EditingState, fromEditBarButtonWithSystemItem systemItem: UIBarButtonItem.SystemItem) -> EditingState
func emptyStateDidChange(_ empty: Bool)
var currentTheme: Theme { get }
}
public class CollectionViewEditController: NSObject, UIGestureRecognizerDelegate, ActionDelegate {
let collectionView: UICollectionView
struct SwipeInfo {
let translation: CGFloat
let velocity: CGFloat
let state: SwipeState
}
var swipeInfoByIndexPath: [IndexPath: SwipeInfo] = [:]
var configuredCellsByIndexPath: [IndexPath: SwipeableCell] = [:]
var activeCell: SwipeableCell? {
guard let indexPath = activeIndexPath else {
return nil
}
return collectionView.cellForItem(at: indexPath) as? SwipeableCell
}
public var isActive: Bool {
return activeIndexPath != nil
}
var activeIndexPath: IndexPath? {
didSet {
if activeIndexPath != nil {
editingState = .swiping
} else {
editingState = isCollectionViewEmpty ? .empty : .none
}
}
}
var isRTL: Bool = false
var initialSwipeTranslation: CGFloat = 0
let maxExtension: CGFloat = 10
let panGestureRecognizer: UIPanGestureRecognizer
let longPressGestureRecognizer: UILongPressGestureRecognizer
public init(collectionView: UICollectionView) {
self.collectionView = collectionView
panGestureRecognizer = UIPanGestureRecognizer()
longPressGestureRecognizer = UILongPressGestureRecognizer()
super.init()
panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture))
longPressGestureRecognizer.addTarget(self, action: #selector(handleLongPressGesture))
if let gestureRecognizers = self.collectionView.gestureRecognizers {
var otherGestureRecognizer: UIGestureRecognizer
for gestureRecognizer in gestureRecognizers {
otherGestureRecognizer = gestureRecognizer is UIPanGestureRecognizer ? panGestureRecognizer : longPressGestureRecognizer
gestureRecognizer.require(toFail: otherGestureRecognizer)
}
}
panGestureRecognizer.delegate = self
self.collectionView.addGestureRecognizer(panGestureRecognizer)
longPressGestureRecognizer.delegate = self
longPressGestureRecognizer.minimumPressDuration = 0.05
longPressGestureRecognizer.require(toFail: panGestureRecognizer)
self.collectionView.addGestureRecognizer(longPressGestureRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(close), name: UIApplication.willResignActiveNotification, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
public func swipeTranslationForItem(at indexPath: IndexPath) -> CGFloat? {
return swipeInfoByIndexPath[indexPath]?.translation
}
public func configureSwipeableCell(_ cell: UICollectionViewCell, forItemAt indexPath: IndexPath, layoutOnly: Bool) {
guard
!layoutOnly,
let cell = cell as? SwipeableCell,
cell.isSwipeEnabled else {
return
}
cell.actions = availableActions(at: indexPath)
configuredCellsByIndexPath[indexPath] = cell
guard let info = swipeInfoByIndexPath[indexPath] else {
return
}
cell.swipeState = info.state
cell.actionsView.delegate = self
cell.swipeTranslation = info.translation
}
public func deconfigureSwipeableCell(_ cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
configuredCellsByIndexPath.removeValue(forKey: indexPath)
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return panGestureRecognizerShouldBegin(panGestureRecognizer)
}
if gestureRecognizer === longPressGestureRecognizer {
return longPressGestureRecognizerShouldBegin(longPressGestureRecognizer)
}
return false
}
public weak var delegate: ActionDelegate?
public func didPerformAction(_ action: Action) -> Bool {
if let cell = activeCell {
return cell.actionsView.updateConfirmationImage(for: action) {
self.delegatePerformingAction(action)
}
}
return self.delegatePerformingAction(action)
}
private func delegatePerformingAction(_ action: Action) -> Bool {
guard action.indexPath == activeIndexPath else {
return self.delegate?.didPerformAction(action) ?? false
}
let activatedAction = action.type == .delete ? action : nil
closeActionPane(with: activatedAction) { (finished) in
_ = self.delegate?.didPerformAction(action)
}
return true
}
public func willPerformAction(_ action: Action) -> Bool {
return delegate?.willPerformAction(action) ?? didPerformAction(action)
}
public func availableActions(at indexPath: IndexPath) -> [Action] {
return delegate?.availableActions(at: indexPath) ?? []
}
func panGestureRecognizerShouldBegin(_ gestureRecognizer: UIPanGestureRecognizer) -> Bool {
var shouldBegin = false
defer {
if !shouldBegin {
closeActionPane()
}
}
guard delegate != nil else {
return shouldBegin
}
let position = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: position) else {
return shouldBegin
}
let velocity = gestureRecognizer.velocity(in: collectionView)
// Begin only if there's enough x velocity.
if abs(velocity.y) >= abs(velocity.x) {
return shouldBegin
}
defer {
if let indexPath = activeIndexPath {
initialSwipeTranslation = swipeInfoByIndexPath[indexPath]?.translation ?? 0
}
}
isRTL = collectionView.effectiveUserInterfaceLayoutDirection == .rightToLeft
let isOpenSwipe = isRTL ? velocity.x > 0 : velocity.x < 0
if !isOpenSwipe { // only allow closing swipes on active cells
shouldBegin = indexPath == activeIndexPath
return shouldBegin
}
if activeIndexPath != nil && activeIndexPath != indexPath {
closeActionPane()
}
guard activeIndexPath == nil else {
shouldBegin = true
return shouldBegin
}
activeIndexPath = indexPath
guard let cell = activeCell, !cell.actions.isEmpty && cell.isSwipeEnabled else {
activeIndexPath = nil
return shouldBegin
}
shouldBegin = true
return shouldBegin
}
func longPressGestureRecognizerShouldBegin(_ gestureRecognizer: UILongPressGestureRecognizer) -> Bool {
guard let cell = activeCell else {
return false
}
// Don't allow the cancel gesture to recognize if any of the touches are within the actions view.
let numberOfTouches = gestureRecognizer.numberOfTouches
for touchIndex in 0..<numberOfTouches {
let touchLocation = gestureRecognizer.location(ofTouch: touchIndex, in: cell.actionsView)
let touchedActionsView = cell.actionsView.bounds.contains(touchLocation)
return !touchedActionsView
}
return true
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UILongPressGestureRecognizer {
return true
}
if gestureRecognizer is UIPanGestureRecognizer {
return otherGestureRecognizer is UILongPressGestureRecognizer
}
return false
}
private lazy var batchEditToolbarViewController: BatchEditToolbarViewController = {
let batchEditToolbarViewController = BatchEditToolbarViewController(nibName: "BatchEditToolbarViewController", bundle: Bundle.wmf)
batchEditToolbarViewController.items = self.batchEditToolbarItems
return batchEditToolbarViewController
}()
public var batchEditToolbarView: UIView {
return self.batchEditToolbarViewController.view
}
@objc func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let indexPath = activeIndexPath, let cell = activeCell, cell.isSwipeEnabled else {
return
}
cell.actionsView.delegate = self
let deltaX = sender.translation(in: collectionView).x
let velocityX = sender.velocity(in: collectionView).x
var swipeTranslation = deltaX + initialSwipeTranslation
let normalizedSwipeTranslation = isRTL ? swipeTranslation : -swipeTranslation
let normalizedMaxSwipeTranslation = abs(cell.swipeTranslationWhenOpen)
switch sender.state {
case .began:
cell.swipeState = .swiping
fallthrough
case .changed:
if normalizedSwipeTranslation < 0 {
let normalizedSqrt = maxExtension * log(abs(normalizedSwipeTranslation))
swipeTranslation = isRTL ? 0 - normalizedSqrt : normalizedSqrt
}
if normalizedSwipeTranslation > normalizedMaxSwipeTranslation {
let maxWidth = normalizedMaxSwipeTranslation
let delta = normalizedSwipeTranslation - maxWidth
swipeTranslation = isRTL ? maxWidth + (maxExtension * log(delta)) : 0 - maxWidth - (maxExtension * log(delta))
}
cell.swipeTranslation = swipeTranslation
swipeInfoByIndexPath[indexPath] = SwipeInfo(translation: swipeTranslation, velocity: velocityX, state: .swiping)
case .cancelled:
fallthrough
case .failed:
fallthrough
case .ended:
let isOpen: Bool
let velocityAdjustment = 0.3 * velocityX
if isRTL {
isOpen = swipeTranslation + velocityAdjustment > 0.5 * cell.swipeTranslationWhenOpen
} else {
isOpen = swipeTranslation + velocityAdjustment < 0.5 * cell.swipeTranslationWhenOpen
}
if isOpen {
openActionPane()
} else {
closeActionPane()
}
fallthrough
default:
break
}
}
@objc func handleLongPressGesture(_ sender: UILongPressGestureRecognizer) {
guard activeIndexPath != nil else {
return
}
switch sender.state {
case .ended:
closeActionPane()
default:
break
}
}
var areSwipeActionsDisabled: Bool = false {
didSet {
longPressGestureRecognizer.isEnabled = !areSwipeActionsDisabled
panGestureRecognizer.isEnabled = !areSwipeActionsDisabled
}
}
// MARK: - States
func openActionPane(_ completion: @escaping (Bool) -> Void = {_ in }) {
collectionView.allowsSelection = false
guard let cell = activeCell, let indexPath = activeIndexPath else {
completion(false)
return
}
let targetTranslation = cell.swipeTranslationWhenOpen
let velocity = swipeInfoByIndexPath[indexPath]?.velocity ?? 0
swipeInfoByIndexPath[indexPath] = SwipeInfo(translation: targetTranslation, velocity: velocity, state: .open)
cell.swipeState = .open
animateActionPane(of: cell, to: targetTranslation, with: velocity, completion: completion)
}
func closeActionPane(with expandedAction: Action? = nil, _ completion: @escaping (Bool) -> Void = {_ in }) {
collectionView.allowsSelection = true
guard let cell = activeCell, let indexPath = activeIndexPath else {
completion(false)
return
}
activeIndexPath = nil
let velocity = swipeInfoByIndexPath[indexPath]?.velocity ?? 0
swipeInfoByIndexPath[indexPath] = nil
if let expandedAction = expandedAction {
let translation = isRTL ? cell.bounds.width : 0 - cell.bounds.width
animateActionPane(of: cell, to: translation, with: velocity, expandedAction: expandedAction, completion: { (finished) in
// don't set isSwiping to false so that the expanded action stays visible through the fade
completion(finished)
})
} else {
animateActionPane(of: cell, to: 0, with: velocity, completion: { (finished: Bool) in
cell.swipeState = self.activeIndexPath == indexPath ? .swiping : .closed
completion(finished)
})
}
}
func animateActionPane(of cell: SwipeableCell, to targetTranslation: CGFloat, with swipeVelocity: CGFloat, expandedAction: Action? = nil, completion: @escaping (Bool) -> Void = {_ in }) {
if let action = expandedAction {
UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
cell.actionsView.expand(action)
cell.swipeTranslation = targetTranslation
cell.layoutIfNeeded()
}, completion: completion)
return
}
let initialSwipeTranslation = cell.swipeTranslation
let animationTranslation = targetTranslation - initialSwipeTranslation
let animationDuration: TimeInterval = 0.3
let distanceInOneSecond = animationTranslation / CGFloat(animationDuration)
let unitSpeed = distanceInOneSecond == 0 ? 0 : swipeVelocity / distanceInOneSecond
UIView.animate(withDuration: animationDuration, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: unitSpeed, options: [.allowUserInteraction, .beginFromCurrentState], animations: {
cell.swipeTranslation = targetTranslation
cell.layoutIfNeeded()
}, completion: completion)
}
// MARK: - Batch editing
public var isShowingDefaultCellOnly: Bool = false {
didSet {
guard oldValue != isShowingDefaultCellOnly else {
return
}
editingState = isCollectionViewEmpty || isShowingDefaultCellOnly ? .empty : .none
}
}
public weak var navigationDelegate: CollectionViewEditControllerNavigationDelegate? {
willSet {
batchEditToolbarViewController.remove()
}
didSet {
guard oldValue !== navigationDelegate else {
return
}
if navigationDelegate == nil {
editingState = .unknown
} else {
editingState = isCollectionViewEmpty || isShowingDefaultCellOnly ? .empty : .none
}
}
}
private var editableCells: [BatchEditableCell] {
guard let editableCells = collectionView.visibleCells as? [BatchEditableCell] else {
return []
}
return editableCells
}
public var isBatchEditing: Bool {
return editingState == .open
}
private var editingState: EditingState = .unknown {
didSet {
guard editingState != oldValue else {
return
}
editingStateDidChange(from: oldValue, to: editingState)
}
}
private func editingStateDidChange(from oldValue: EditingState, to newValue: EditingState) {
let rightBarButtonSystemItem: UIBarButtonItem.SystemItem?
let leftBarButtonSystemItem: UIBarButtonItem.SystemItem?
var isRightBarButtonEnabled = !(isCollectionViewEmpty || isShowingDefaultCellOnly) || shouldShowEditButtonsForEmptyState
switch newValue {
case .editing:
areSwipeActionsDisabled = true
leftBarButtonSystemItem = .cancel
rightBarButtonSystemItem = .done
isRightBarButtonEnabled = true
if oldValue == .open {
transformBatchEditPane(for: editingState)
}
case .swiping:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
case .open:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .cancel
transformBatchEditPane(for: editingState)
case .closed:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
transformBatchEditPane(for: editingState)
case .empty:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = shouldShowEditButtonsForEmptyState ? .edit : nil
isBatchEditToolbarHidden = true
default:
leftBarButtonSystemItem = nil
rightBarButtonSystemItem = .edit
}
var rightButton: SystemBarButton?
var leftButton: SystemBarButton?
if let barButtonSystemItem = rightBarButtonSystemItem {
rightButton = SystemBarButton(with: barButtonSystemItem, target: self, action: #selector(barButtonPressed(_:)))
}
if let barButtonSystemItem = leftBarButtonSystemItem {
leftButton = SystemBarButton(with: barButtonSystemItem, target: self, action: #selector(barButtonPressed(_:)))
}
leftButton?.tag = editingState.tag
rightButton?.tag = editingState.tag
rightButton?.isEnabled = isRightBarButtonEnabled
let font = rightBarButtonSystemItem != .edit ? UIFont.wmf_font(.semiboldBody) : UIFont.wmf_font(.body)
let attributes = [NSAttributedString.Key.font: font]
rightButton?.setTitleTextAttributes(attributes, for: .normal)
leftButton?.setTitleTextAttributes(attributes, for: .normal)
navigationDelegate?.didChangeEditingState(from: oldValue, to: editingState, rightBarButton: rightButton, leftBarButton: leftButton)
}
private func transformBatchEditPane(for state: EditingState, animated: Bool = true) {
guard !isCollectionViewEmpty else {
return
}
let willOpen = state == .open
areSwipeActionsDisabled = willOpen
collectionView.allowsMultipleSelection = willOpen
isBatchEditToolbarHidden = !willOpen
for cell in editableCells {
guard cell.isBatchEditable else {
continue
}
if animated {
// ensure layout is in the start anim state
cell.isBatchEditing = !willOpen
cell.layoutIfNeeded()
UIView.animate(withDuration: 0.3, delay: 0.1, options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseInOut], animations: {
cell.isBatchEditing = willOpen
cell.layoutIfNeeded()
})
} else {
cell.isBatchEditing = willOpen
cell.layoutIfNeeded()
}
if let themeableCell = cell as? Themeable, let navigationDelegate = navigationDelegate {
themeableCell.apply(theme: navigationDelegate.currentTheme)
}
}
if !willOpen {
selectedIndexPaths.forEach({ collectionView.deselectItem(at: $0, animated: true) })
batchEditToolbarViewController.setItemsEnabled(false)
}
}
@objc public func close() {
guard editingState == .open || editingState == .swiping else {
return
}
if editingState == .swiping {
editingState = .none
} else {
editingState = .closed
}
closeActionPane()
}
private func emptyStateDidChange() {
if isCollectionViewEmpty || isShowingDefaultCellOnly {
editingState = .empty
} else {
editingState = .none
}
navigationDelegate?.emptyStateDidChange(isCollectionViewEmpty)
}
public var isCollectionViewEmpty: Bool = true {
didSet {
guard oldValue != isCollectionViewEmpty else {
return
}
emptyStateDidChange()
}
}
public var shouldShowEditButtonsForEmptyState: Bool = false
@objc private func barButtonPressed(_ sender: SystemBarButton) {
guard let navigationDelegate = navigationDelegate else {
assertionFailure("Unable to set new editing state - navigationDelegate is nil")
return
}
guard let systemItem = sender.systemItem else {
assertionFailure("Unable to set new editing state - systemItem is nil")
return
}
let currentEditingState = editingState
if currentEditingState == .swiping {
closeActionPane()
}
editingState = navigationDelegate.newEditingState(for: currentEditingState, fromEditBarButtonWithSystemItem: systemItem)
}
public func changeEditingState(to newEditingState: EditingState) {
editingState = newEditingState
}
public var isTextEditing: Bool = false {
didSet {
editingState = isTextEditing ? .editing : .done
}
}
public var isClosed: Bool {
let isClosed = editingState != .open
if !isClosed {
batchEditToolbarViewController.setItemsEnabled(!selectedIndexPaths.isEmpty)
}
return isClosed
}
public func transformBatchEditPaneOnScroll() {
transformBatchEditPane(for: editingState, animated: false)
}
private var selectedIndexPaths: [IndexPath] {
return collectionView.indexPathsForSelectedItems ?? []
}
private var isBatchEditToolbarHidden: Bool = true {
didSet {
self.navigationDelegate?.didSetBatchEditToolbarHidden(batchEditToolbarViewController, isHidden: self.isBatchEditToolbarHidden, with: self.batchEditToolbarItems)
}
}
private var batchEditToolbarActions: [BatchEditToolbarAction] {
guard let delegate = delegate, let actions = delegate.availableBatchEditToolbarActions else {
return []
}
return actions
}
@objc public func didPerformBatchEditToolbarAction(with sender: UIBarButtonItem) {
guard let delegate = delegate else {
assertionFailure("delegate should be set by now")
editingState = .closed
return
}
guard let didPerformBatchEditToolbarAction = delegate.didPerformBatchEditToolbarAction else {
assertionFailure("delegate should implement didPerformBatchEditToolbarAction")
editingState = .closed
return
}
let action = batchEditToolbarActions[sender.tag]
didPerformBatchEditToolbarAction(action) { finished in
if finished {
self.editingState = .closed
}
}
}
private lazy var batchEditToolbarItems: [UIButton] = {
var buttons: [UIButton] = []
for (index, action) in batchEditToolbarActions.enumerated() {
let button = UIButton(type: .system)
button.addTarget(self, action: #selector(didPerformBatchEditToolbarAction(with:)), for: .touchUpInside)
button.tag = index
button.setTitle(action.title, for: UIControl.State.normal)
buttons.append(button)
button.isEnabled = false
}
return buttons
}()
}