Skip to content

Commit

Permalink
Queue changes while reorder events are settling; so that mutative upd…
Browse files Browse the repository at this point in the history
…ates after the edit do not cause crashes within UICollectionView; it does not account for mutations while a reorder is settling.
  • Loading branch information
kyleve committed Jul 27, 2021
1 parent 5798ee5 commit b4a13ce
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,15 @@ struct Toggle : Element {
{
super.init(frame: frame)

self.addTarget(self, action: #selector(toggled), for: .valueChanged)
self.addTarget(self, action: #selector(didToggleValue), for: .valueChanged)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}

@objc func toggled()
@objc func didToggleValue()
{
self.onToggle(self.isOn)
}
Expand Down
26 changes: 26 additions & 0 deletions Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class PaymentTypesViewController : ListViewController {

list.layout = .table { table in
table.layout.interSectionSpacingWithNoFooter = 10.0
table.layout.padding.bottom = 100
}

let types = self.types
Expand Down Expand Up @@ -48,9 +49,15 @@ final class PaymentTypesViewController : ListViewController {

section.header = HeaderFooter(PaymentTypeHeader(title: SectionID.disabled.title))

section.reordering.minItemCount = 0

section += types.filter { $0.isEnabled == false }
.sorted { $0.sortOrder < $1.sortOrder }
.map(makeItem(with:))

if section.items.isEmpty {
section += EmptyRow()
}
}
}

Expand Down Expand Up @@ -131,6 +138,25 @@ fileprivate struct PaymentTypeHeader : BlueprintHeaderFooterContent, Equatable {
}
.inset(uniform: 15.0)
}

var background: Element? {
Box(backgroundColor: .white)
}
}

fileprivate struct EmptyRow : BlueprintItemContent, Equatable {

var identifierValue: String {
""
}

func element(with info: ApplyItemContentInfo) -> Element {
Label(text: "No Contents") {
$0.font = .systemFont(ofSize: 16.0, weight: .semibold)
$0.color = .lightGray
}
.inset(uniform: 15.0)
}
}

fileprivate struct PaymentTypeRow : BlueprintItemContent {
Expand Down
38 changes: 35 additions & 3 deletions ListableUI/Sources/Layout/CollectionViewLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,6 @@ final class CollectionViewLayout : UICollectionViewLayout
let view = self.collectionView!
let context = context as! InvalidationContext

super.invalidateLayout(with: context)

// Handle Moved Items

self.isReordering = context.interactiveMoveAction != nil
Expand All @@ -176,13 +174,16 @@ final class CollectionViewLayout : UICollectionViewLayout
}

case .complete(_):
break
self.sendEndQueuingEditsAfterDelay()

case .cancelled(let info):
self.layout.content.move(from: info.from, to: info.to)
self.sendEndQueuingEditsAfterDelay()
}
}

super.invalidateLayout(with: context)

// Handle View Width Changing

context.viewPropertiesChanged = self.viewProperties != CollectionViewLayoutProperties(collectionView: view)
Expand All @@ -192,6 +193,35 @@ final class CollectionViewLayout : UICollectionViewLayout
self.neededLayoutType.merge(with: context)
}

private func sendEndQueuingEditsAfterDelay() {

///
/// Hello! Welcome to the source code. You're probably wondering why this perform after runloop hack is here.
///
/// Well, it is because `UICollectionView` does not play well with removals that occur synchronously
/// as a result of a reorder being messaged.
///
/// Please, consider the following:
///
/// 1) A user begins dragging an item.
/// 2) They drop the item at the last point in the list; (2,1). The collection view records this index path (2,1).
/// 3) Via `collectionView(_:moveItemAt:to:)`, we notify the observer(s) of the change.
/// 4) Synchronously via that notification, they remove the item at (2,0), moving the item now at (2,1) to (2,0).
///
/// Unfortunately, this causes `super.invalidateLayout(with: context)` to then fail with an invalid
/// index path; because it seems to take one runloop to let the reorder "settle" through the collection view –
/// most notably, the `context.targetIndexPathsForInteractivelyMovingItems` contains an
/// invalid index path – the item which was previously at (2,1) is still there, when it should now be at (2,0).
///
/// So thus, we queue updates a runloop to let the collection view figure its internal state out before we begin
/// processing any further updates 🥴.
///

OperationQueue.main.addOperation {
self.delegate.listViewShouldEndQueueingEditsForReorder()
}
}

override func invalidationContext(
forInteractivelyMovingItems targetIndexPaths: [IndexPath],
withTargetPosition targetPosition: CGPoint,
Expand Down Expand Up @@ -641,6 +671,8 @@ public protocol CollectionViewLayoutDelegate : AnyObject
) -> ListLayoutContent

func listViewLayoutDidLayoutContents()

func listViewShouldEndQueueingEditsForReorder()
}


Expand Down
97 changes: 97 additions & 0 deletions ListableUI/Sources/ListView/ListChangesQueue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// ListChangesQueue.swift
// ListableUI
//
// Created by Kyle Van Essen on 7/19/21.
//

import Foundation


/// Used to queue updates into a list view.
/// Note: This type is only safe to use from the main thread.
final class ListChangesQueue {

/// Adds a synchronous block to the queue, marked as done once the block exits.
func add(_ block : @escaping () -> ()) {
self.waiting.append(.init(block))
self.runIfNeeded()
}

/// Set by consumers to enable and disable queueing during a reorder event.
var isQueuingForReorderEvent : Bool = false {
didSet {
self.runIfNeeded()
}
}

/// Prevents processing other events in the queue.
///
/// Note: Right now this just checks `isQueuingForReorderEvent`, but may check more props in the future.
var isPaused : Bool {
self.isQueuingForReorderEvent
}

/// Operations waiting to execute.
private(set) var waiting : [Operation] = []

/// Invoked to continue processing queue events.
private func runIfNeeded() {
precondition(Thread.isMainThread)

/// Nothing to do if we're currently paused!
guard self.isPaused == false else {
return
}

while let next = self.waiting.popFirst() {
guard case .new(let new) = next.state else {
fatalError("State of enqueued operation was wrong")
}

/// Ok, we have a runnable operation; let's run it.

next.state = .done

new.body()
}
}
}


extension ListChangesQueue {

final class Operation {

fileprivate(set) var state : State

init(_ body : @escaping () -> ()) {
self.state = .new(.init(body: body))
}

enum State {
case new(New)
case done

struct New {
let body : () -> ()
}
}
}
}


fileprivate extension Array {

mutating func popFirst() -> Element? {
guard self.isEmpty == false else {
return nil
}

let first = self[0]

self.remove(at: 0)

return first
}
}
10 changes: 10 additions & 0 deletions ListableUI/Sources/ListView/ListView.DataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ internal extension ListView
return
}

///
/// Mark us as queuing for re-orders, to prevent destructive edits which could break the collection
/// view's layout while the re-order event settles.
///
/// Later on, the call to `listViewShouldEndQueueingEditsForReorder` will set this value to false.
///
/// See `sendEndQueuingEditsAfterDelay` for a more in-depth explanation.
///
self.view.updateQueue.isQueuingForReorderEvent = true

/// Perform the change in our data source.

self.storage.moveItem(from: from, to: to)
Expand Down
7 changes: 5 additions & 2 deletions ListableUI/Sources/ListView/ListView.Delegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,13 @@ extension ListView
)
}

func listViewLayoutDidLayoutContents()
{
func listViewLayoutDidLayoutContents() {
self.view.visibleContent.update(with: self.view)
}

func listViewShouldEndQueueingEditsForReorder() {
self.view.updateQueue.isQueuingForReorderEvent = false
}

// MARK: UIScrollViewDelegate

Expand Down
47 changes: 29 additions & 18 deletions ListableUI/Sources/ListView/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ public final class ListView : UIView, KeyboardObserverDelegate
self.stateObserver = ListStateObserver()

self.collectionView.isPrefetchingEnabled = false

self.collectionView.dataSource = self.dataSource

self.collectionView.delegate = self.delegate
self.collectionView.dataSource = self.dataSource

// Super init.

Expand Down Expand Up @@ -684,25 +684,36 @@ public final class ListView : UIView, KeyboardObserverDelegate
self.configure(with: description)
}

let updateQueue = ListChangesQueue()

public func configure(with properties : ListProperties)
{
let animated = properties.animatesChanges

self.appearance = properties.appearance
self.behavior = properties.behavior
self.autoScrollAction = properties.autoScrollAction
self.scrollIndicatorInsets = properties.scrollIndicatorInsets
self.collectionView.accessibilityIdentifier = properties.accessibilityIdentifier
self.debuggingIdentifier = properties.debuggingIdentifier
self.actions = properties.actions

self.stateObserver = properties.stateObserver

self.environment = properties.environment

self.set(layout: properties.layout, animated: animated)
/// We enqueue these changes into the update queue to ensure they are not applied
/// before it is safe to do so. Currently, "safe" means "during the application of a reorder".
///
/// See `CollectionViewLayout.sendEndQueuingEditsAfterDelay()` for more.

self.setContent(animated: animated, properties.content)
self.updateQueue.add { [weak self] in
guard let self = self else { return }

let animated = properties.animatesChanges

self.appearance = properties.appearance
self.behavior = properties.behavior
self.autoScrollAction = properties.autoScrollAction
self.scrollIndicatorInsets = properties.scrollIndicatorInsets
self.collectionView.accessibilityIdentifier = properties.accessibilityIdentifier
self.debuggingIdentifier = properties.debuggingIdentifier
self.actions = properties.actions

self.stateObserver = properties.stateObserver

self.environment = properties.environment

self.set(layout: properties.layout, animated: animated)

self.setContent(animated: animated, properties.content)
}
}

private func setContentFromSource(animated : Bool = false)
Expand Down
53 changes: 53 additions & 0 deletions ListableUI/Tests/ListView/ListChangesQueueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// ListChangesQueueTests.swift
// ListableUI-Unit-Tests
//
// Created by Kyle Van Essen on 7/24/21.
//

import XCTest
@testable import ListableUI


class ListChangesQueueTests : XCTestCase {

func test_queue() {

let queue = ListChangesQueue()

XCTAssertFalse(queue.isPaused)
XCTAssertFalse(queue.isQueuingForReorderEvent)

var calls : [Int] = []

queue.add {
calls += [1]
}

XCTAssertEqual(queue.waiting.count, 0)
XCTAssertEqual(calls, [1])

queue.isQueuingForReorderEvent = true

XCTAssertTrue(queue.isPaused)
XCTAssertTrue(queue.isQueuingForReorderEvent)

queue.add {
calls += [2]
}

queue.add {
calls += [3]
}

XCTAssertEqual(queue.waiting.count, 2)
XCTAssertEqual(calls, [1])

queue.isQueuingForReorderEvent = false

XCTAssertFalse(queue.isPaused)
XCTAssertFalse(queue.isQueuingForReorderEvent)

XCTAssertEqual(calls, [1, 2, 3])
}
}

0 comments on commit b4a13ce

Please sign in to comment.