Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ final class PerformanceDemoViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()

title = "Performance (10K Items)"
title = "Performance (100K Items)"
view.backgroundColor = .systemBackground

navigationItem.rightBarButtonItems = [
Expand Down Expand Up @@ -71,8 +71,8 @@ final class PerformanceDemoViewController: UIViewController {
]

private func loadInitialData() {
// Create 10,000 items
items = (0..<10_000).map { index in
// Create 100,000 items
items = (0..<100_000).map { index in
let color = colors[index % colors.count]
let item = PerformanceItem(id: nextItemID, color: color)
nextItemID += 1
Expand Down
2 changes: 1 addition & 1 deletion Example/MagazineLayoutExample/RootMenuViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ private enum DemoOption: String, CaseIterable {
case .messageThread:
return "Bottom-to-top layout with pagination"
case .performance:
return "10,000 items with traditional data source"
return "100,000 items with traditional data source"
}
}

Expand Down
25 changes: 21 additions & 4 deletions MagazineLayout/LayoutCore/SectionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ struct SectionModel {
numberOfRows = 0

updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: 0)
calculateElementFramesIfNecessary()

if !MagazineLayout._enableExperimentalOptimizations {
calculateElementFramesIfNecessary()
}
}

// MARK: Internal
Expand Down Expand Up @@ -257,15 +260,22 @@ struct SectionModel {
}

mutating func removeFooter() -> Bool {
guard let indexOfFooter = indexOfFooterRow() else {
guard footerModel != nil else {
return false
}
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter)
// `indexOfFooterRow()` is `nil` if the section hasn't been laid out yet (deferred layout
// calculation). In that case the whole section is already invalidated from row 0, so there's no
// additional row to invalidate.
if let indexOfFooter = indexOfFooterRow() {
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter)
}
footerModel = nil
return true
}

mutating func updateItemHeight(toPreferredHeight preferredHeight: CGFloat, atIndex index: Int) {
calculateElementFramesIfNecessary()

// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
itemModels.withUnsafeMutableBufferPointer { directlyMutableItemModels in
Expand All @@ -292,6 +302,8 @@ struct SectionModel {
}

mutating func updateHeaderHeight(toPreferredHeight preferredHeight: CGFloat) {
calculateElementFramesIfNecessary()

headerModel?.preferredHeight = preferredHeight

if let indexOfHeaderRow = indexOfHeaderRow(), let headerModel = headerModel {
Expand All @@ -312,6 +324,8 @@ struct SectionModel {
}

mutating func updateFooterHeight(toPreferredHeight preferredHeight: CGFloat) {
calculateElementFramesIfNecessary()

footerModel?.preferredHeight = preferredHeight

if let indexOfFooterRow = indexOfFooterRow(), let footerModel = footerModel {
Expand Down Expand Up @@ -393,7 +407,10 @@ struct SectionModel {
}

private func indexOfFooterRow() -> Int? {
guard footerModel != nil else { return nil }
// `numberOfRows` is 0 until the section's element frames have been calculated. With deferred
// layout calculation, the footer's row index isn't known yet in that state, so we return `nil`
// rather than a bogus `numberOfRows - 1` (which would be -1).
guard footerModel != nil, numberOfRows > 0 else { return nil }
return numberOfRows - 1
}

Expand Down
108 changes: 85 additions & 23 deletions MagazineLayout/Public/MagazineLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public final class MagazineLayout: UICollectionViewLayout {

// MARK: Public

/// A temporary flag to enable safely testing some optimizations.
public static var _enableExperimentalOptimizations = false

/// The vertical layout direction of items in the collection view. This property changes the behavior of
/// scroll-position-preservation when performing batch updates or when the collection view's bounds changes.
public var verticalLayoutDirection = MagazineLayoutVerticalLayoutDirection.topToBottom
Expand All @@ -60,12 +63,14 @@ public final class MagazineLayout: UICollectionViewLayout {

override public var collectionViewContentSize: CGSize {
guard collectionView != nil else { return .zero }
return layoutState.contentSize
return updatedLayoutState().contentSize
}

override public func prepare() {
super.prepare()

_delegateMagazineLayout = currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout

// Save the previous collection view width if necessary
if prepareActions.contains(.cachePreviousWidth) {
cachedCollectionViewWidth = currentCollectionView.bounds.width
Expand All @@ -78,9 +83,15 @@ public final class MagazineLayout: UICollectionViewLayout {
hasPinnedHeaderOrFooter = false
}

var reusableIndexPath = IndexPath(item: 0, section: 0)

// Update layout metrics if necessary
if prepareActions.contains(.updateLayoutMetrics) {
for sectionIndex in 0..<modelState.numberOfSections {
if Self._enableExperimentalOptimizations {
reusableIndexPath.section = sectionIndex
}

let sectionMetrics = metricsForSection(atIndex: sectionIndex)
modelState.updateMetrics(to: sectionMetrics, forSectionAtIndex: sectionIndex)

Expand All @@ -103,25 +114,37 @@ public final class MagazineLayout: UICollectionViewLayout {
}

let numberOfItems = modelState.numberOfItems(inSectionAtIndex: sectionIndex)
var indexPath = IndexPath(item: 0, section: sectionIndex)
for itemIndex in 0..<numberOfItems {
let indexPath = IndexPath(item: itemIndex, section: sectionIndex)
modelState.updateItemSizeMode(to: sizeModeForItem(at: indexPath), forItemAt: indexPath)
if Self._enableExperimentalOptimizations {
reusableIndexPath.item = itemIndex
modelState.updateItemSizeMode(to: sizeModeForItem(at: indexPath), forItemAt: reusableIndexPath)
} else {
indexPath = IndexPath(item: itemIndex, section: sectionIndex)
modelState.updateItemSizeMode(to: sizeModeForItem(at: indexPath), forItemAt: indexPath)
}
}
}
}

// Recreate section models from scratch if necessary
if prepareActions.contains(.recreateSectionModels) {
layoutStateBeforeRecreateSectionModels = LayoutState(
modelState: layoutState.modelState.copy(),
modelState: modelState.copy(),
bounds: currentCollectionView.bounds,
contentInset: contentInset,
scale: scale,
verticalLayoutDirection: verticalLayoutDirection)

var sections = [SectionModel]()
for sectionIndex in 0..<currentCollectionView.numberOfSections {
let sectionModel = sectionModelForSection(atIndex: sectionIndex)
if Self._enableExperimentalOptimizations {
reusableIndexPath.section = sectionIndex
}

let sectionModel = sectionModelForSection(
atIndex: sectionIndex,
reusableIndexPath: &reusableIndexPath)
sections.append(sectionModel)
}

Expand All @@ -133,14 +156,15 @@ public final class MagazineLayout: UICollectionViewLayout {

override public func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
let layoutStateBeforeCollectionViewUpdates = LayoutState(
modelState: layoutState.modelState.copy(),
modelState: modelState.copy(),
bounds: currentCollectionView.bounds,
contentInset: contentInset,
scale: scale,
verticalLayoutDirection: verticalLayoutDirection)
self.layoutStateBeforeCollectionViewUpdates = layoutStateBeforeCollectionViewUpdates

var updates = [CollectionViewUpdate<SectionModel, ItemModel>]()
var reusableIndexPath = IndexPath(item: 0, section: 0)

for updateItem in updateItems {
let updateAction = updateItem.updateAction
Expand All @@ -154,7 +178,12 @@ public final class MagazineLayout: UICollectionViewLayout {
}

if indexPath.item == NSNotFound {
let sectionModel = sectionModelForSection(atIndex: indexPath.section)
if Self._enableExperimentalOptimizations {
reusableIndexPath.section = indexPath.section
}
let sectionModel = sectionModelForSection(
atIndex: indexPath.section,
reusableIndexPath: &reusableIndexPath)
updates.append(.sectionReload(sectionIndex: indexPath.section, newSection: sectionModel))
} else {
let itemModel = itemModelForItem(at: indexPath)
Expand Down Expand Up @@ -182,7 +211,12 @@ public final class MagazineLayout: UICollectionViewLayout {
}

if indexPath.item == NSNotFound {
let sectionModel = sectionModelForSection(atIndex: indexPath.section)
if Self._enableExperimentalOptimizations {
reusableIndexPath.section = indexPath.section
}
let sectionModel = sectionModelForSection(
atIndex: indexPath.section,
reusableIndexPath: &reusableIndexPath)
updates.append(.sectionInsert(sectionIndex: indexPath.section, newSection: sectionModel))
} else {
let itemModel = itemModelForItem(at: indexPath)
Expand Down Expand Up @@ -227,6 +261,7 @@ public final class MagazineLayout: UICollectionViewLayout {

if let layoutStateBeforeCollectionViewUpdates{
let targetContentOffsetAnchor = layoutStateBeforeCollectionViewUpdates.targetContentOffsetAnchor
let layoutState = updatedLayoutState()
let targetYOffset = layoutState.yOffset(
for: targetContentOffsetAnchor,
isPerformingBatchUpdates: true)
Expand All @@ -248,7 +283,7 @@ public final class MagazineLayout: UICollectionViewLayout {

if currentCollectionView.bounds.size != oldBounds.size {
layoutStateBeforeAnimatedBoundsChange = LayoutState(
modelState: layoutState.modelState.copy(),
modelState: modelState.copy(),
bounds: oldBounds,
contentInset: contentInset,
scale: scale,
Expand Down Expand Up @@ -670,13 +705,15 @@ public final class MagazineLayout: UICollectionViewLayout {
withOriginalAttributes: originalAttributes) as! MagazineLayoutInvalidationContext
context.invalidateLayoutMetrics = false

let layoutState = updatedLayoutState()

switch preferredAttributes.representedElementCategory {
case .cell:
let targetContentOffsetAnchor = (
layoutStateBeforeRecreateSectionModels ??
layoutStateBeforeCollectionViewUpdates ??
layoutStateBeforeAnimatedBoundsChange ??
self.layoutState
layoutState
).targetContentOffsetAnchor
let targetYOffsetBefore = layoutState.yOffset(
for: targetContentOffsetAnchor,
Expand Down Expand Up @@ -823,7 +860,7 @@ public final class MagazineLayout: UICollectionViewLayout {
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
}

let yOffset = layoutState.yOffset(
let yOffset = updatedLayoutState().yOffset(
for: layoutStateBefore.targetContentOffsetAnchor,
isPerformingBatchUpdates: layoutStateBeforeCollectionViewUpdates != nil)

Expand Down Expand Up @@ -885,6 +922,8 @@ public final class MagazineLayout: UICollectionViewLayout {
private var cachedCollectionViewWidth: CGFloat?
private var previousContentInset: UIEdgeInsets?

private var _delegateMagazineLayout: UICollectionViewDelegateMagazineLayout?

private var currentCollectionView: UICollectionView {
guard let collectionView = collectionView else {
preconditionFailure("`collectionView` should not be `nil`")
Expand Down Expand Up @@ -917,10 +956,6 @@ public final class MagazineLayout: UICollectionViewLayout {
height: currentCollectionView.bounds.height - contentInset.top - contentInset.bottom + refreshControlHeight)
}

private var delegateMagazineLayout: UICollectionViewDelegateMagazineLayout? {
return currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout
}

private var scale: CGFloat {
collectionView?.traitCollection.nonZeroDisplayScale ?? 1
}
Expand All @@ -929,18 +964,31 @@ public final class MagazineLayout: UICollectionViewLayout {
currentCollectionView.adjustedContentInset
}

private var layoutState: LayoutState {
private var modelState: ModelState {
if MagazineLayout._enableExperimentalOptimizations {
_layoutState.modelState
} else {
updatedLayoutState().modelState
}
}

private var delegateMagazineLayout: UICollectionViewDelegateMagazineLayout? {
if MagazineLayout._enableExperimentalOptimizations {
_delegateMagazineLayout
} else {
currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout
}
}

/// Relatively expensive compared to just grabbing the `_layoutState`; only use if you need updated metrics.
private func updatedLayoutState() -> LayoutState {
_layoutState.bounds = currentCollectionView.bounds
_layoutState.contentInset = contentInset
_layoutState.scale = scale
_layoutState.verticalLayoutDirection = verticalLayoutDirection
return _layoutState
}

private var modelState: ModelState {
layoutState.modelState
}

private func metricsForSection(atIndex sectionIndex: Int) -> MagazineLayoutSectionMetrics {
guard let delegateMagazineLayout = delegateMagazineLayout else {
return MagazineLayoutSectionMetrics.defaultSectionMetrics(
Expand Down Expand Up @@ -1045,9 +1093,23 @@ public final class MagazineLayout: UICollectionViewLayout {
}
}

private func sectionModelForSection(atIndex sectionIndex: Int) -> SectionModel {
let itemModels = (0..<currentCollectionView.numberOfItems(inSection: sectionIndex)).map {
itemModelForItem(at: IndexPath(item: $0, section: sectionIndex))
private func sectionModelForSection(
atIndex sectionIndex: Int,
reusableIndexPath: inout IndexPath)
-> SectionModel
{
let numberOfItems = currentCollectionView.numberOfItems(inSection: sectionIndex)
var itemModels = [ItemModel]()
itemModels.reserveCapacity(numberOfItems)

for itemIndex in 0..<numberOfItems {
if Self._enableExperimentalOptimizations {
reusableIndexPath.item = itemIndex
itemModels.append(itemModelForItem(at: reusableIndexPath))
} else {
itemModels.append(itemModelForItem(at: IndexPath(item: itemIndex, section: sectionIndex)))
}

}

return SectionModel(
Expand Down
7 changes: 2 additions & 5 deletions MagazineLayout/Public/MagazineLayoutInvalidationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,12 @@ import UIKit
/// Used to indicate that collection view properties and/or delegate layout metrics changed.
public final class MagazineLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {

/// A temporary flag to enable safely testing a change to how layout invalidation works.
public static var _invalidateLayoutMetricsDefaultValue = true

/// Indicates whether to recompute the positions and sizes of elements based on the current collection view and delegate layout
/// metrics.
///
/// Defaults to `false`. Set to `true` when delegate-provided layout values (e.g. item size
/// Set to `true` when delegate-provided layout values (e.g. item size
/// modes, header/footer visibility, section metrics) have changed and the layout needs to
/// re-query the delegate.
public var invalidateLayoutMetrics = _invalidateLayoutMetricsDefaultValue
public var invalidateLayoutMetrics = !MagazineLayout._enableExperimentalOptimizations

}
Loading