Push from command line

This commit is contained in:
Deniz Duezgoeren
2019-08-12 11:20:21 +02:00
parent 3a919dcb23
commit f1345eac14
512 changed files with 103288 additions and 1930 deletions
@@ -0,0 +1,37 @@
// Created by bryankeller on 10/16/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
import Foundation
/// Represents the layout information for a background in a section.
struct BackgroundModel {
// MARK: Lifecycle
init() {
id = NSUUID().uuidString
originInSection = .zero
size = .zero
}
// MARK: Internal
let id: String
var originInSection: CGPoint
var size: CGSize
}
@@ -0,0 +1,38 @@
// Created by Roman Laitarenko on 1/31/19.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
import Foundation
/// Represents the layout information for a footer in a section.
struct FooterModel {
// MARK: Lifecycle
init(heightMode: MagazineLayoutFooterHeightMode, height: CGFloat, pinToVisibleBounds: Bool) {
self.heightMode = heightMode
self.pinToVisibleBounds = pinToVisibleBounds
originInSection = .zero
size = CGSize(width: 0, height: height)
}
// MARK: Internal
var heightMode: MagazineLayoutFooterHeightMode
var pinToVisibleBounds: Bool
var originInSection: CGPoint
var size: CGSize
var preferredHeight: CGFloat?
}
+39
View File
@@ -0,0 +1,39 @@
// Created by bryankeller on 10/16/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
import Foundation
/// Represents the layout information for a header in a section.
struct HeaderModel {
// MARK: Lifecycle
init(heightMode: MagazineLayoutHeaderHeightMode, height: CGFloat, pinToVisibleBounds: Bool) {
self.heightMode = heightMode
self.pinToVisibleBounds = pinToVisibleBounds
originInSection = .zero
size = CGSize(width: 0, height: height)
}
// MARK: Internal
var heightMode: MagazineLayoutHeaderHeightMode
var pinToVisibleBounds: Bool
var originInSection: CGPoint
var size: CGSize
var preferredHeight: CGFloat?
}
@@ -0,0 +1,40 @@
// Created by bryankeller on 7/9/17.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
import Foundation
/// Represents the layout information for an item in a section.
struct ItemModel {
// MARK: Lifecycle
init(sizeMode: MagazineLayoutItemSizeMode, height: CGFloat) {
id = NSUUID().uuidString
self.sizeMode = sizeMode
originInSection = .zero
size = CGSize(width: 0, height: height)
}
// MARK: Internal
let id: String
var sizeMode: MagazineLayoutItemSizeMode
var originInSection: CGPoint
var size: CGSize
var preferredHeight: CGFloat?
}
File diff suppressed because it is too large Load Diff
+640
View File
@@ -0,0 +1,640 @@
// Created by bryankeller on 7/9/17.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
import Foundation
/// Represents the layout information for a section.
struct SectionModel {
// MARK: Lifecycle
init(
itemModels: [ItemModel],
headerModel: HeaderModel?,
footerModel: FooterModel?,
backgroundModel: BackgroundModel?,
metrics: MagazineLayoutSectionMetrics)
{
id = NSUUID().uuidString
self.itemModels = itemModels
self.headerModel = headerModel
self.footerModel = footerModel
self.backgroundModel = backgroundModel
self.metrics = metrics
calculatedHeight = 0
numberOfRows = 0
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: 0)
calculateElementFramesIfNecessary()
}
// MARK: Internal
let id: String
private(set) var headerModel: HeaderModel?
private(set) var footerModel: FooterModel?
private(set) var backgroundModel: BackgroundModel?
var visibleBounds: CGRect?
var numberOfItems: Int {
return itemModels.count
}
func idForItemModel(atIndex index: Int) -> String {
return itemModels[index].id
}
func indexForItemModel(withID id: String) -> Int? {
return itemModels.index { $0.id == id }
}
func itemModel(atIndex index: Int) -> ItemModel {
return itemModels[index]
}
func preferredHeightForItemModel(atIndex index: Int) -> CGFloat? {
return itemModels[index].preferredHeight
}
mutating func calculateHeight() -> CGFloat {
calculateElementFramesIfNecessary()
return calculatedHeight
}
mutating func calculateFrameForItem(atIndex index: Int) -> CGRect {
calculateElementFramesIfNecessary()
var origin = itemModels[index].originInSection
if let rowIndex = rowIndicesForItemIndices[index] {
origin.y += rowOffsetTracker?.offsetForRow(at: rowIndex) ?? 0
} else {
assertionFailure("Expected a row and a row height for item at \(index).")
}
return CGRect(origin: origin, size: itemModels[index].size)
}
mutating func calculateFrameForHeader(
inSectionVisibleBounds sectionVisibleBounds: CGRect)
-> CGRect?
{
guard headerModel != nil else { return nil }
calculateElementFramesIfNecessary()
// `headerModel` is a value type that might be mutated in `calculateElementFramesIfNecessary`,
// so we can't use a copy made before that code executes (for example, in a
// `guard let headerModel = headerModel else { ... }` at the top of this function).
if let headerModel = headerModel {
let originY: CGFloat
if headerModel.pinToVisibleBounds {
originY = max(
min(
sectionVisibleBounds.minY,
calculateHeight() -
metrics.sectionInsets.bottom -
(footerModel?.size.height ?? 0) -
headerModel.size.height),
headerModel.originInSection.y)
} else {
originY = headerModel.originInSection.y
}
return CGRect(
origin: CGPoint(x: headerModel.originInSection.x, y: originY),
size: headerModel.size)
} else {
return nil
}
}
mutating func calculateFrameForFooter(
inSectionVisibleBounds sectionVisibleBounds: CGRect)
-> CGRect?
{
guard footerModel != nil else { return nil }
calculateElementFramesIfNecessary()
var origin = footerModel?.originInSection
if let rowIndex = indexOfFooterRow() {
origin?.y += rowOffsetTracker?.offsetForRow(at: rowIndex) ?? 0
} else {
assertionFailure("Expected a row and a corresponding section footer.")
}
// `footerModel` is a value type that might be mutated in `calculateElementFramesIfNecessary`,
// so we can't use a copy made before that code executes (for example, in a
// `guard let footerModel = footerModel else { ... }` at the top of this function).
if let footerModel = footerModel, let origin = origin {
let originY: CGFloat
if footerModel.pinToVisibleBounds {
originY = min(
max(
sectionVisibleBounds.maxY - footerModel.size.height,
metrics.sectionInsets.top + (headerModel?.size.height ?? 0)),
origin.y)
} else {
originY = origin.y
}
return CGRect(
origin: CGPoint(x: footerModel.originInSection.x, y: originY),
size: footerModel.size)
} else {
return nil
}
}
mutating func calculateFrameForBackground() -> CGRect? {
let calculatedHeight = calculateHeight()
backgroundModel?.originInSection = CGPoint(
x: metrics.sectionInsets.left,
y: metrics.sectionInsets.top)
backgroundModel?.size.width = metrics.width
backgroundModel?.size.height = calculatedHeight -
metrics.sectionInsets.top -
metrics.sectionInsets.bottom
if let backgroundModel = backgroundModel {
return CGRect(
origin: CGPoint(x: backgroundModel.originInSection.x, y: backgroundModel.originInSection.y),
size: backgroundModel.size)
} else {
return nil
}
}
@discardableResult
mutating func deleteItemModel(atIndex indexOfDeletion: Int) -> ItemModel {
updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex: indexOfDeletion)
return itemModels.remove(at: indexOfDeletion)
}
mutating func insert(_ itemModel: ItemModel, atIndex indexOfInsertion: Int) {
updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex: indexOfInsertion)
itemModels.insert(itemModel, at: indexOfInsertion)
}
mutating func updateMetrics(to metrics: MagazineLayoutSectionMetrics) {
guard self.metrics != metrics else { return }
self.metrics = metrics
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: 0)
}
mutating func updateItemSizeMode(to sizeMode: MagazineLayoutItemSizeMode, atIndex index: Int) {
// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
let itemModelsPointer = UnsafeMutableRawPointer(mutating: &itemModels)
let directlyMutableItemModels = itemModelsPointer.assumingMemoryBound(to: ItemModel.self)
directlyMutableItemModels[index].sizeMode = sizeMode
if case let .static(staticHeight) = sizeMode.heightMode {
directlyMutableItemModels[index].size.height = staticHeight
}
updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex: index)
}
mutating func setHeader(_ headerModel: HeaderModel) {
let oldPreferredHeight = self.headerModel?.preferredHeight
self.headerModel = headerModel
if case let .static(staticHeight) = headerModel.heightMode {
self.headerModel?.size.height = staticHeight
} else if case .dynamic = headerModel.heightMode {
self.headerModel?.preferredHeight = oldPreferredHeight
}
if let indexOfHeader = indexOfHeaderRow() {
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfHeader)
}
}
mutating func setFooter(_ footerModel: FooterModel) {
let oldPreferredHeight = self.footerModel?.preferredHeight
self.footerModel = footerModel
if case let .static(staticHeight) = footerModel.heightMode {
self.footerModel?.size.height = staticHeight
} else if case .dynamic = footerModel.heightMode {
self.footerModel?.preferredHeight = oldPreferredHeight
}
if let indexOfFooter = indexOfFooterRow() {
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter)
}
}
mutating func removeHeader() {
if let indexOfHeader = indexOfHeaderRow() {
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfHeader)
}
headerModel = nil
}
mutating func removeFooter() {
if let indexOfFooter = indexOfFooterRow() {
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter)
}
footerModel = nil
}
mutating func updateItemHeight(toPreferredHeight preferredHeight: CGFloat, atIndex index: Int) {
// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
let itemModelsPointer = UnsafeMutableRawPointer(mutating: &itemModels)
let directlyMutableItemModels = itemModelsPointer.assumingMemoryBound(to: ItemModel.self)
directlyMutableItemModels[index].preferredHeight = preferredHeight
if
let rowIndex = rowIndicesForItemIndices[index],
let rowHeight = itemRowHeightsForRowIndices[rowIndex]
{
let newRowHeight = updateHeightsForItemsInRow(at: rowIndex)
let heightDelta = newRowHeight - rowHeight
calculatedHeight += heightDelta
let firstAffectedRowIndex = rowIndex + 1
if firstAffectedRowIndex < numberOfRows {
rowOffsetTracker?.addOffset(heightDelta, forRowsStartingAt: firstAffectedRowIndex)
}
} else {
assertionFailure("Expected a row and a row height for item at \(index).")
return
}
}
mutating func updateHeaderHeight(toPreferredHeight preferredHeight: CGFloat) {
headerModel?.preferredHeight = preferredHeight
if let indexOfHeaderRow = indexOfHeaderRow(), let headerModel = headerModel {
let rowHeight = headerModel.size.height
let newRowHeight = updateHeaderHeight(withMetricsFrom: headerModel)
let heightDelta = newRowHeight - rowHeight
calculatedHeight += heightDelta
let firstAffectedRowIndex = indexOfHeaderRow + 1
if firstAffectedRowIndex < numberOfRows {
rowOffsetTracker?.addOffset(heightDelta, forRowsStartingAt: firstAffectedRowIndex)
}
} else {
assertionFailure("Expected a row, a row height, and a corresponding section header.")
return
}
}
mutating func updateFooterHeight(toPreferredHeight preferredHeight: CGFloat) {
footerModel?.preferredHeight = preferredHeight
if let indexOfFooterRow = indexOfFooterRow(), let footerModel = footerModel {
let rowHeight = footerModel.size.height
let newRowHeight = updateFooterHeight(withMetricsFrom: footerModel)
let heightDelta = newRowHeight - rowHeight
calculatedHeight += heightDelta
let firstAffectedRowIndex = indexOfFooterRow + 1
if firstAffectedRowIndex < numberOfRows {
rowOffsetTracker?.addOffset(heightDelta, forRowsStartingAt: firstAffectedRowIndex)
}
} else {
assertionFailure("Expected a row, a row height, and a corresponding section footer.")
return
}
}
mutating func setBackground(_ backgroundModel: BackgroundModel) {
self.backgroundModel = backgroundModel
// No need to invalidate since the background doesn't affect the layout.
}
mutating func removeBackground() {
backgroundModel = nil
// No need to invalidate since the background doesn't affect the layout.
}
// MARK: Private
private var numberOfRows: Int
private var itemModels: [ItemModel]
private var metrics: MagazineLayoutSectionMetrics
private var calculatedHeight: CGFloat
private var indexOfFirstInvalidatedRow: Int? {
didSet {
guard indexOfFirstInvalidatedRow != nil else { return }
applyRowOffsetsIfNecessary()
}
}
private var itemIndicesForRowIndices = [Int: [Int]]()
private var rowIndicesForItemIndices = [Int: Int]()
private var itemRowHeightsForRowIndices = [Int: CGFloat]()
private var rowOffsetTracker: RowOffsetTracker?
private func maxYForItemsRow(atIndex rowIndex: Int) -> CGFloat? {
guard
let itemIndices = itemIndicesForRowIndices[rowIndex],
let itemY = itemIndices.first.flatMap({ itemModels[$0].originInSection.y }),
let itemHeight = itemIndices.map({ itemModels[$0].size.height }).max() else
{
return nil
}
return itemY + itemHeight
}
private func indexOfHeaderRow() -> Int? {
guard headerModel != nil else { return nil }
return 0
}
private func indexOfFirstItemsRow() -> Int? {
guard numberOfItems > 0 else { return nil }
return headerModel == nil ? 0 : 1
}
private func indexOfLastItemsRow() -> Int? {
guard numberOfItems > 0 else { return nil }
return rowIndicesForItemIndices[numberOfItems - 1]
}
private func indexOfFooterRow() -> Int? {
guard footerModel != nil else { return nil }
return numberOfRows - 1
}
private mutating func updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex changedIndex: Int) {
guard
let indexOfCurrentRow = rowIndicesForItemIndices[changedIndex],
indexOfCurrentRow > 0 else
{
indexOfFirstInvalidatedRow = rowIndicesForItemIndices[0] ?? 0
return
}
updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfCurrentRow - 1)
}
private mutating func updateIndexOfFirstInvalidatedRowIfNecessary(
toProposedIndex proposedIndex: Int)
{
indexOfFirstInvalidatedRow = min(proposedIndex, indexOfFirstInvalidatedRow ?? proposedIndex)
}
private mutating func applyRowOffsetsIfNecessary() {
guard let rowOffsetTracker = rowOffsetTracker else { return }
for rowIndex in 0..<numberOfRows {
let rowOffset = rowOffsetTracker.offsetForRow(at: rowIndex)
switch rowIndex {
case indexOfHeaderRow(): headerModel?.originInSection.y += rowOffset
case indexOfFooterRow(): footerModel?.originInSection.y += rowOffset
default:
for itemIndex in itemIndicesForRowIndices[rowIndex] ?? [] {
itemModels[itemIndex].originInSection.y += rowOffset
}
}
}
self.rowOffsetTracker = nil
}
private mutating func calculateElementFramesIfNecessary() {
guard var rowIndex = indexOfFirstInvalidatedRow else { return }
guard rowIndex >= 0 else {
assertionFailure("Invalid `rowIndex` / `indexOfFirstInvalidatedRow` (\(rowIndex)).")
return
}
// Clean up item / row / height mappings starting at our `indexOfFirstInvalidatedRow`; we'll
// make new mappings for those row indices as we do layout calculations below. Since all
// item / row index mappings before `indexOfFirstInvalidatedRow` are still valid, we'll leave
// those alone.
for rowIndexKey in itemIndicesForRowIndices.keys {
guard rowIndexKey >= rowIndex else { continue }
if let itemIndex = itemIndicesForRowIndices[rowIndexKey]?.first {
rowIndicesForItemIndices[itemIndex] = nil
}
itemIndicesForRowIndices[rowIndexKey] = nil
itemRowHeightsForRowIndices[rowIndex] = nil
}
// Header frame calculation
if rowIndex == indexOfHeaderRow(), let existingHeaderModel = headerModel {
rowIndex = 1
headerModel?.originInSection = CGPoint(
x: metrics.sectionInsets.left,
y: metrics.sectionInsets.top)
headerModel?.size.width = metrics.width
updateHeaderHeight(withMetricsFrom: existingHeaderModel)
}
var currentY: CGFloat
// Item frame calculations
let startingItemIndex: Int
if
let indexOfLastItemInPreviousRow = itemIndicesForRowIndices[rowIndex - 1]?.last,
indexOfLastItemInPreviousRow + 1 < numberOfItems,
let maxYForPreviousRow = maxYForItemsRow(atIndex: rowIndex - 1)
{
// There's a previous row of items, so we'll use the max Y of that row as the starting place
// for the current row of items.
startingItemIndex = indexOfLastItemInPreviousRow + 1
currentY = maxYForPreviousRow + metrics.verticalSpacing
} else if (headerModel == nil && rowIndex == 0) || (headerModel != nil && rowIndex == 1) {
// Our starting row doesn't exist yet, so we'll lay out our first row of items.
startingItemIndex = 0
currentY = (headerModel?.originInSection.y ?? metrics.sectionInsets.top) +
(headerModel?.size.height ?? 0)
} else {
// Our starting row is after the last row of items, so we'll skip item layout.
startingItemIndex = numberOfItems
if
let lastRowIndex = indexOfLastItemsRow(),
rowIndex > lastRowIndex,
let maxYOfLastRowOfItems = maxYForItemsRow(atIndex: lastRowIndex)
{
currentY = maxYOfLastRowOfItems
} else {
currentY = (headerModel?.originInSection.y ?? metrics.sectionInsets.top) +
(headerModel?.size.height ?? 0)
}
}
// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
let itemModelsPointer = UnsafeMutableRawPointer(mutating: &itemModels)
let directlyMutableItemModels = itemModelsPointer.assumingMemoryBound(to: ItemModel.self)
var indexInCurrentRow = 0
for itemIndex in startingItemIndex..<numberOfItems {
// Create item / row index mappings
itemIndicesForRowIndices[rowIndex] = itemIndicesForRowIndices[rowIndex] ?? []
itemIndicesForRowIndices[rowIndex]?.append(itemIndex)
rowIndicesForItemIndices[itemIndex] = rowIndex
let itemModel = itemModels[itemIndex]
if itemIndex == 0 {
// Apply top item inset now that we're laying out items
currentY += metrics.itemInsets.top
}
let currentLeadingMargin: CGFloat
let availableWidthForItems: CGFloat
if itemModel.sizeMode.widthMode == .fullWidth(respectsHorizontalInsets: false) {
currentLeadingMargin = metrics.sectionInsets.left
availableWidthForItems = metrics.width
} else {
currentLeadingMargin = metrics.sectionInsets.left + metrics.itemInsets.left
availableWidthForItems = metrics.width - metrics.itemInsets.left - metrics.itemInsets.right
}
let totalSpacing = metrics.horizontalSpacing * (itemModel.sizeMode.widthMode.widthDivisor - 1)
let itemWidth = round(
(availableWidthForItems - totalSpacing) / itemModel.sizeMode.widthMode.widthDivisor)
let itemX = CGFloat(indexInCurrentRow) *
itemWidth + CGFloat(indexInCurrentRow) *
metrics.horizontalSpacing + currentLeadingMargin
let itemY = currentY
directlyMutableItemModels[itemIndex].originInSection = CGPoint(x: itemX, y: itemY)
directlyMutableItemModels[itemIndex].size.width = itemWidth
if
(indexInCurrentRow == Int(itemModel.sizeMode.widthMode.widthDivisor) - 1) ||
(itemIndex == numberOfItems - 1) ||
(itemIndex < numberOfItems - 1 && itemModels[itemIndex + 1].sizeMode.widthMode != itemModel.sizeMode.widthMode)
{
// We've reached the end of the current row, or there are no more items to lay out, or we're
// about to lay out an item with a different width mode. In all cases, we're done laying out
// the current row of items.
let heightOfTallestItemInCurrentRow = updateHeightsForItemsInRow(at: rowIndex)
currentY += heightOfTallestItemInCurrentRow
indexInCurrentRow = 0
// If there are more items to layout, add vertical spacing and increment the row index
if itemIndex < numberOfItems - 1 {
currentY += metrics.verticalSpacing
rowIndex += 1
}
} else {
// We're still adding to the current row
indexInCurrentRow += 1
}
}
if numberOfItems > 0 {
// Apply bottom item inset now that we're done laying out items
currentY += metrics.itemInsets.bottom
}
// Footer frame calculations
if let existingFooterModel = footerModel {
rowIndex += 1
footerModel?.originInSection = CGPoint(x: metrics.sectionInsets.left, y: currentY)
footerModel?.size.width = metrics.width
updateFooterHeight(withMetricsFrom: existingFooterModel)
}
numberOfRows = rowIndex + 1
// Final height calculation
calculatedHeight = currentY + (footerModel?.size.height ?? 0) + metrics.sectionInsets.bottom
// The background frame is calculated just-in-time, since its value doesn't affect the layout.
// Create a row offset tracker now that we know how many rows we have
rowOffsetTracker = RowOffsetTracker(numberOfRows: numberOfRows)
// Mark the layout as clean / no longer invalid
indexOfFirstInvalidatedRow = nil
}
private mutating func updateHeightsForItemsInRow(at rowIndex: Int) -> CGFloat {
guard let indicesForItemsInRow = itemIndicesForRowIndices[rowIndex] else {
assertionFailure("Expected item indices for row \(rowIndex).")
return 0
}
// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
let itemModelsPointer = UnsafeMutableRawPointer(mutating: &itemModels)
let directlyMutableItemModels = itemModelsPointer.assumingMemoryBound(to: ItemModel.self)
var heightOfTallestItem = CGFloat(0)
var stretchToTallestItemInRowItemIndices = Set<Int>()
for itemIndex in indicesForItemsInRow {
let preferredHeight = itemModels[itemIndex].preferredHeight
let height = itemModels[itemIndex].size.height
directlyMutableItemModels[itemIndex].size.height = preferredHeight ?? height
// Handle stretch to tallest item in row height mode for current row
if itemModels[itemIndex].sizeMode.heightMode == .dynamicAndStretchToTallestItemInRow {
stretchToTallestItemInRowItemIndices.insert(itemIndex)
}
heightOfTallestItem = max(heightOfTallestItem, itemModels[itemIndex].size.height)
}
for stretchToTallestItemInRowItemIndex in stretchToTallestItemInRowItemIndices{
directlyMutableItemModels[stretchToTallestItemInRowItemIndex].size.height = heightOfTallestItem
}
itemRowHeightsForRowIndices[rowIndex] = heightOfTallestItem
return heightOfTallestItem
}
@discardableResult
private mutating func updateHeaderHeight(withMetricsFrom headerModel: HeaderModel) -> CGFloat {
let height = headerModel.preferredHeight ?? headerModel.size.height
self.headerModel?.size.height = height
return height
}
@discardableResult
private mutating func updateFooterHeight(withMetricsFrom footerModel: FooterModel) -> CGFloat {
let height = footerModel.preferredHeight ?? footerModel.size.height
self.footerModel?.size.height = height
return height
}
}
@@ -0,0 +1,34 @@
// Created by bryankeller on 2/25/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// Represents a collection view update with more type-expressivity than
/// `UICollectionViewUpdateItem`.
enum CollectionViewUpdate<SectionModel, ItemModel> {
case sectionReload(sectionIndex: Int, newSection: SectionModel)
case itemReload(itemIndexPath: IndexPath, newItem: ItemModel)
case sectionDelete(sectionIndex: Int)
case itemDelete(itemIndexPath: IndexPath)
case sectionInsert(sectionIndex: Int, newSection: SectionModel)
case itemInsert(itemIndexPath: IndexPath, newItem: ItemModel)
case sectionMove(initialSectionIndex: Int, finalSectionIndex: Int)
case itemMove(initialItemIndexPath: IndexPath, finalItemIndexPath: IndexPath)
}
@@ -0,0 +1,46 @@
// Created by bryankeller on 8/13/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// Represents the location of an item in a section.
///
/// Initializing a `ElementLocation` is measurably faster than initializing an `IndexPath`.
/// On an iPhone X, compiled with -Os optimizations, it's about 35x faster to initialize this struct
/// compared to an `IndexPath`.
struct ElementLocation: Hashable {
// MARK: Lifecycle
init(elementIndex: Int, sectionIndex: Int) {
self.elementIndex = elementIndex
self.sectionIndex = sectionIndex
}
init(indexPath: IndexPath) {
elementIndex = indexPath.item
sectionIndex = indexPath.section
}
// MARK: Internal
let elementIndex: Int
let sectionIndex: Int
var indexPath: IndexPath {
return IndexPath(item: elementIndex, section: sectionIndex)
}
}
@@ -0,0 +1,134 @@
// Created by bryankeller on 8/17/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
// MARK: - ElementLocationFramePairs
/// Stores pairs of `ElementLocationFramePair`s in an efficient way for appending to and
/// iterating over.
///
/// The main reason this exists (and why its implementation uses a singly-linked-list) is so that,
/// as we find the section and item index and frame for an element in a visible rect, we can append
/// it to this data structure without copy-on-write performance issues, or array buffer resizing
/// issues. (The cost of appending to an array is much more expensive due to copy-on-write and the
/// backing buffer needing to be resized).
struct ElementLocationFramePairs {
// MARK: Lifecycle
init() { }
init(elementLocationFramePair: ElementLocationFramePair) {
append(elementLocationFramePair)
}
// MARK: Internal
mutating func append(_ elementLocationFramePair: ElementLocationFramePair) {
if first == nil {
first = elementLocationFramePair
} else {
last.next = elementLocationFramePair
}
last = elementLocationFramePair
}
// MARK: Fileprivate
fileprivate var first: ElementLocationFramePair?
// MARK: Private
private var last: ElementLocationFramePair!
}
// MARK: Sequence
extension ElementLocationFramePairs: Sequence {
func makeIterator() -> ElementLocationFramePairsIterator {
return ElementLocationFramePairsIterator(self)
}
}
// MARK: - ElementLocationFramePairsIterator
/// Used for iterating through `ElementLocationFramePairs` instances
struct ElementLocationFramePairsIterator: IteratorProtocol {
typealias Element = ElementLocationFramePair
// MARK: Lifecycle
init(_ elementLocationFramePairs: ElementLocationFramePairs) {
self.elementLocationFramePairs = elementLocationFramePairs
}
// MARK: Internal
mutating func next() -> ElementLocationFramePair? {
if lastReturnedElement == nil {
lastReturnedElement = elementLocationFramePairs.first
} else {
lastReturnedElement = lastReturnedElement?.next
}
return lastReturnedElement
}
// MARK: Private
private let elementLocationFramePairs: ElementLocationFramePairs
private var lastReturnedElement: ElementLocationFramePair?
}
// MARK: - ElementLocationFramePair
/// Encapsulates a `ElementLocation` and a `CGRect` frame for an element.
final class ElementLocationFramePair {
// MARK: Lifecycle
init(elementLocation: ElementLocation, frame: CGRect) {
self.elementLocation = elementLocation
self.frame = frame
}
// MARK: Internal
let elementLocation: ElementLocation
let frame: CGRect
// MARK: Fileprivate
fileprivate var next: ElementLocationFramePair?
}
// MARK: Equatable
extension ElementLocationFramePair: Equatable {
static func == (lhs: ElementLocationFramePair, rhs: ElementLocationFramePair) -> Bool {
return lhs.elementLocation == rhs.elementLocation && lhs.frame == rhs.frame
}
}
@@ -0,0 +1,31 @@
// Created by bryankeller on 10/15/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
extension MagazineLayoutItemWidthMode {
/// Returns the divisor for a given item width mode.
///
/// When divided into the width of the collection view, the result equals the width of the item
/// before taking into account horizontal insets.
var widthDivisor: CGFloat {
switch self {
case .fullWidth: return 1
case let .fractionalWidth(divisor): return CGFloat(divisor)
}
}
}
@@ -0,0 +1,107 @@
// Created by bryankeller on 10/26/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// Encapsulates all layout-affecting metrics relating to a section
struct MagazineLayoutSectionMetrics: Equatable {
// MARK: Lifecycle
init(
forSectionAtIndex sectionIndex: Int,
in collectionView: UICollectionView,
layout: UICollectionViewLayout,
delegate: UICollectionViewDelegateMagazineLayout)
{
collectionViewWidth = collectionView.bounds.width
if #available(iOS 11.0, *) {
collectionViewContentInset = collectionView.adjustedContentInset
} else {
collectionViewContentInset = collectionView.contentInset
}
verticalSpacing = delegate.collectionView(
collectionView,
layout: layout,
verticalSpacingForElementsInSectionAtIndex: sectionIndex)
horizontalSpacing = delegate.collectionView(
collectionView,
layout: layout,
horizontalSpacingForItemsInSectionAtIndex: sectionIndex)
sectionInsets = delegate.collectionView(
collectionView,
layout: layout,
insetsForSectionAtIndex: sectionIndex)
itemInsets = delegate.collectionView(
collectionView,
layout: layout,
insetsForItemsInSectionAtIndex: sectionIndex)
}
private init(
collectionViewWidth: CGFloat,
collectionViewContentInset: UIEdgeInsets,
verticalSpacing: CGFloat,
horizontalSpacing: CGFloat,
sectionInsets: UIEdgeInsets,
itemInsets: UIEdgeInsets)
{
self.collectionViewWidth = collectionViewWidth
self.collectionViewContentInset = collectionViewContentInset
self.verticalSpacing = verticalSpacing
self.horizontalSpacing = horizontalSpacing
self.sectionInsets = sectionInsets
self.itemInsets = itemInsets
}
// MARK: Internal
var width: CGFloat {
return collectionViewWidth -
collectionViewContentInset.left -
collectionViewContentInset.right -
sectionInsets.left -
sectionInsets.right
}
var verticalSpacing: CGFloat
var horizontalSpacing: CGFloat
var sectionInsets: UIEdgeInsets
var itemInsets: UIEdgeInsets
static func defaultSectionMetrics(
forCollectionViewWidth width: CGFloat)
-> MagazineLayoutSectionMetrics
{
return MagazineLayoutSectionMetrics(
collectionViewWidth: width,
collectionViewContentInset: .zero,
verticalSpacing: MagazineLayout.Default.VerticalSpacing,
horizontalSpacing: MagazineLayout.Default.HorizontalSpacing,
sectionInsets: MagazineLayout.Default.SectionInsets,
itemInsets: MagazineLayout.Default.ItemInsets)
}
// MARK: Private
private let collectionViewWidth: CGFloat
private let collectionViewContentInset: UIEdgeInsets
}
@@ -0,0 +1,79 @@
// Created by bryankeller on 5/23/19.
// Copyright © 2019 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
/// Tracks offsets for rows using a Segment Tree for O(log n) lookups and updates.
struct RowOffsetTracker {
// MARK: Lifecycle
init(numberOfRows: Int) {
self.numberOfRows = numberOfRows
rowOffsets = Array(repeating: 0, count: 2 * numberOfRows)
}
// MARK: Internal
mutating func addOffset(_ offset: CGFloat, forRowsStartingAt rowIndex: Int) {
var rowIndex = rowIndex + numberOfRows
// Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes
// and Swift retain / release calls.
let rowOffsetsPointer = UnsafeMutableRawPointer(mutating: &rowOffsets)
let directlyMutableRowOffsets = rowOffsetsPointer.assumingMemoryBound(to: CGFloat.self)
directlyMutableRowOffsets[rowIndex] = rowOffsets[rowIndex] + offset
while rowIndex > 1 {
rowIndex /= 2
let leftChild = rowOffsets[2 * rowIndex]
let rightChild = rowOffsets[(2 * rowIndex) + 1]
directlyMutableRowOffsets[rowIndex] = leftChild + rightChild
}
}
func offsetForRow(at rowIndex: Int) -> CGFloat {
var lowerBound = numberOfRows
var upperBound = rowIndex + numberOfRows + 1
var offset = CGFloat(0)
while lowerBound < upperBound {
if lowerBound % 2 != 0 {
offset += rowOffsets[lowerBound]
lowerBound += 1
}
if upperBound % 2 != 0 {
upperBound -= 1
offset += rowOffsets[upperBound]
}
lowerBound /= 2
upperBound /= 2
}
return offset
}
// MARK: Private
private let numberOfRows: Int
private var rowOffsets: [CGFloat]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
// Created by bryankeller on 7/24/17.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// Encapsulates the vertical fitting priority for an element laid out by `MagazineLayout`.
///
/// Used by `UICollectionViewCell` and `UICollectionReusableView` subclasses to determine which
/// vertical fitting priority to pass into
/// `systemLayoutSizeFitting(_:withHorizontalFittingPriority:verticalFittingPriority)` via
/// `preferredLayoutAttributesFitting(_:)`.
public final class MagazineLayoutCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
/// `MagazineLayout` supports self-sizing and static-sizing in the vertical direction. The value
/// of this property will change the layout priority used for sizing.
public var shouldVerticallySelfSize = true
override public func copy(with zone: NSZone? = nil) -> Any {
let copy = super.copy(with: zone) as! MagazineLayoutCollectionViewLayoutAttributes
copy.shouldVerticallySelfSize = shouldVerticallySelfSize
return copy
}
override public func isEqual(_ object: Any?) -> Bool {
return super.isEqual(object) &&
shouldVerticallySelfSize == (object as? MagazineLayoutCollectionViewLayoutAttributes)?.shouldVerticallySelfSize
}
}
@@ -0,0 +1,27 @@
// Created by bryankeller on 9/24/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// `MagazineLayout`'s invalidation context type.
///
/// Used to indicate that collection view properties and/or delegate layout metrics changed.
public final class MagazineLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
/// Indicates whether to recompute the positions and sizes of elements based on the current
/// collection view and delegate layout metrics.
public var invalidateLayoutMetrics = true
}
@@ -0,0 +1,40 @@
// Created by bryankeller on 10/18/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
extension MagazineLayout {
/// Constants for layout sizing and spacing defaults.
public enum Default {
public static let ItemSizeMode = MagazineLayoutItemSizeMode(
widthMode: .fullWidth(respectsHorizontalInsets: true),
heightMode: MagazineLayoutItemHeightMode.static(height: ItemHeight))
public static let HeaderVisibilityMode = MagazineLayoutHeaderVisibilityMode.hidden
public static let FooterVisibilityMode = MagazineLayoutFooterVisibilityMode.hidden
public static let BackgroundVisibilityMode = MagazineLayoutBackgroundVisibilityMode.hidden
public static let ItemHeight: CGFloat = 150
public static let HeaderHeight: CGFloat = 44
public static let FooterHeight: CGFloat = 44
public static let VerticalSpacing: CGFloat = 0
public static let HorizontalSpacing: CGFloat = 0
public static let SectionInsets: UIEdgeInsets = .zero
public static let ItemInsets: UIEdgeInsets = .zero
}
}
@@ -0,0 +1,27 @@
// Created by bryankeller on 10/18/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
extension MagazineLayout {
/// Constants for supported supplementary view element kinds.
public enum SupplementaryViewKind {
public static let sectionHeader = "MagazineLayoutSupplementaryViewKindSectionHeader"
public static let sectionFooter = "MagazineLayoutSupplementaryViewKindSectionFooter"
public static let sectionBackground = "MagazineLayoutSupplementaryViewKindSectionBackground"
}
}
@@ -0,0 +1,27 @@
// Created by bryankeller on 10/15/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// Represents the visibility mode for a background.
public enum MagazineLayoutBackgroundVisibilityMode {
/// This visiblity mode will cause the background to be displayed behind the items and headers in
/// its respective section.
case visible
/// This visibility mode will cause the background to not be visibile behind the items and headers
/// in its respective section.
case hidden
}
@@ -0,0 +1,78 @@
// Created by Roman Laitarenko on 2/4/19.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
// MARK: - MagazineLayoutFooterVisibilityMode
/// Represents the visibility mode for a footer.
public enum MagazineLayoutFooterVisibilityMode {
/// This visibility mode will cause the footer to be displayed using the specified height mode in
/// its respective section. If `pinToVisibleBounds` is true, the footer will pin to the visible
/// bounds of the collection view while its containing section is visible.
case visible(heightMode: MagazineLayoutFooterHeightMode, pinToVisibleBounds: Bool)
/// This visibility mode will cause the footer to not be visibile in its respective section.
case hidden
/// This visibility mode will cause the footer to be displayed using the specified height mode in
/// its respective section.
public static func visible(
heightMode: MagazineLayoutFooterHeightMode)
-> MagazineLayoutFooterVisibilityMode
{
return .visible(heightMode: heightMode, pinToVisibleBounds: false)
}
}
// MARK: - MagazineLayoutFooterHeightMode
/// Represents the vertical sizing mode for a footer.
public enum MagazineLayoutFooterHeightMode {
/// This height mode will force the footer to be displayed with a height equal to `height`.
///
/// To properly support multiline labels, dynamic type, and other technologies that could affect
/// the height of your footers dynamically, consider using the `dynamic` height mode.
case `static`(height: CGFloat)
/// This height mode will cause the footer to self-size in the vertical direction.
///
/// In practice, self-sizing in the vertical direction means that the footer will get its height
/// from the Auto Layout engine. Use this height mode for footers whose height is not known
/// upfront. For example, if you support multiline labels or dynamic type, your height is likely
/// not known until the Auto Layout engine resolves the layout at runtime.
case dynamic
}
// MARK: Equatable
extension MagazineLayoutFooterHeightMode: Equatable {
public static func == (
lhs: MagazineLayoutFooterHeightMode,
rhs: MagazineLayoutFooterHeightMode)
-> Bool
{
switch (lhs, rhs) {
case (.static(let l), .static(let r)): return l == r
case (.dynamic, .dynamic): return true
default: return false
}
}
}
@@ -0,0 +1,79 @@
// Created by bryankeller on 10/15/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
// MARK: - MagazineLayoutHeaderVisibilityMode
/// Represents the visibility mode for a header.
public enum MagazineLayoutHeaderVisibilityMode {
/// This visibility mode will cause the header to be displayed using the specified height mode in
/// its respective section. If `pinToVisibleBounds` is true, the header will pin to the visible
/// bounds of the collection view while its containing section is visible.
case visible(heightMode: MagazineLayoutHeaderHeightMode, pinToVisibleBounds: Bool)
/// This visibility mode will cause the header to not be visibile in its respective section.
case hidden
/// This visibility mode will cause the header to be displayed using the specified height mode in
/// its respective section.
public static func visible(
heightMode: MagazineLayoutHeaderHeightMode)
-> MagazineLayoutHeaderVisibilityMode
{
return .visible(heightMode: heightMode, pinToVisibleBounds: false)
}
}
// MARK: - MagazineLayoutHeaderHeightMode
/// Represents the vertical sizing mode for a header.
public enum MagazineLayoutHeaderHeightMode {
/// This height mode will force the header to be displayed with a height equal to `height`.
///
/// To properly support multiline labels, dynamic type, and other technologies that could affect
/// the height of your headers dynamically, consider using the `dynamic` height mode.
case `static`(height: CGFloat)
/// This height mode will cause the header to self-size in the vertical direction.
///
/// In practice, self-sizing in the vertical direction means that the header will get its height
/// from the Auto Layout engine. Use this height mode for headers whose height is not known
/// upfront. For example, if you support multiline labels or dynamic type, your height is likely
/// not known until the Auto Layout engine resolves the layout at runtime.
case dynamic
}
// MARK: Equatable
extension MagazineLayoutHeaderHeightMode: Equatable {
public static func == (
lhs: MagazineLayoutHeaderHeightMode,
rhs: MagazineLayoutHeaderHeightMode)
-> Bool
{
switch (lhs, rhs) {
case (.static(let l), .static(let r)): return l == r
case (.dynamic, .dynamic): return true
default: return false
}
}
}
@@ -0,0 +1,164 @@
// Created by bryankeller on 10/15/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CoreGraphics
// MARK: - MagazineLayoutItemSizeMode
/// Represents the horizontal and vertical sizing mode for an item.
public struct MagazineLayoutItemSizeMode {
// MARK: Lifecycle
public init(widthMode: MagazineLayoutItemWidthMode, heightMode: MagazineLayoutItemHeightMode) {
self.widthMode = widthMode
self.heightMode = heightMode
}
// MARK: Public
/// The width mode for the item.
public let widthMode: MagazineLayoutItemWidthMode
/// The height mode for the item.
public let heightMode: MagazineLayoutItemHeightMode
}
// MARK: - MagazineLayoutItemWidthMode
/// Represents the horizontal sizing mode for an item.
///
/// Consecutive items with the same width mode will display on the same row until there is no more
/// room.
public enum MagazineLayoutItemWidthMode {
/// Full width items will fill the available width in a section.
///
/// Use this width mode to create lists of items.
/// `respectsHorizontalInsets` specifies whether the item should be edge-to-edge in a section, or
/// if it should be inset by the item insets specified for a section. `respectsHorizontalInsets`
/// does not take into account section insets or the collection view's content inset.
case fullWidth(respectsHorizontalInsets: Bool)
/// Fractional width items will take up `1/divisor` of the available width for a given row of
/// items.
///
/// Use this width mode to create grids of items. Consider using `halfWidth`, `thirdWidth`,
/// `fourthWidth`, or `fifthWidth`, which are equivalent to using `fractionalWidth` with a
/// `divisor` of `2`, `3`, `4`, or `5`, respectively.
///
/// Fractional width items respect `contentInset.left` and `contentInset.right`, and are affected
/// by the horizontal spacing specified for the section in which they're contained. On iOS 11 and
/// higher, they will also take the safe area insets into account if the collection view's
/// `contentInsetAdjustmentBehavior` property is set to a value that respects the safe area.
///
/// - Warning: `divisor` must be greater than `0`. Specifying `0` as the `divisor` is a programmer
/// error and **will result in a runtime crash**.
case fractionalWidth(divisor: UInt)
/// Half width items will take up `1/2` of the available width for a given row of items.
public static var halfWidth: MagazineLayoutItemWidthMode {
return .fractionalWidth(divisor: 2)
}
/// Third width items will take up `1/3` of the available width for a given row of items.
public static var thirdWidth: MagazineLayoutItemWidthMode {
return .fractionalWidth(divisor: 3)
}
/// Fourth width items will take up `1/4` of the available width for a given row of items.
public static var fourthWidth: MagazineLayoutItemWidthMode {
return .fractionalWidth(divisor: 4)
}
/// Fifth width items will take up `1/5` of the available width for a given row of items.
public static var fifthWidth: MagazineLayoutItemWidthMode {
return .fractionalWidth(divisor: 5)
}
}
// MARK: Equatable
extension MagazineLayoutItemWidthMode: Equatable {
public static func == (
lhs: MagazineLayoutItemWidthMode,
rhs: MagazineLayoutItemWidthMode)
-> Bool
{
switch (lhs, rhs) {
case (.fullWidth(let l), .fullWidth(let r)): return l == r
case (.fractionalWidth(let l), .fractionalWidth(let r)): return l == r
default: return false
}
}
}
// MARK: - MagazineLayoutItemHeightMode
/// Represents the vertical sizing mode for an item.
///
/// `MagazineLayout` supports vertically self-sizing and statically sized items. Since height modes
/// are specified for each item, you can mix vertically self-sizing and statically sized items in
/// the same sections, and even in the same rows.
public enum MagazineLayoutItemHeightMode {
/// This height mode mode will cause the item to be displayed with a height equal to `height`.
///
/// To properly support multiline labels, dynamic type, and other technologies that could affect
/// the height of your items dynamically, consider using one of the dynamic height modes.
case `static`(height: CGFloat)
/// This height mode will cause the item to self-size in the vertical direction.
///
/// In practice, self-sizing in the vertical direction means that the item will get its height
/// from the Auto Layout engine. Use this height mode for items whose height is not known upfront.
/// For example, if you support multiline labels or dynamic type, your height is likely not known
/// until the Auto Layout engine resolves the layout at runtime.
case dynamic
/// This height mode will cause the item to self-size in the vertical direction, then resize to
/// match the height of the tallest item in the same row of items.
///
/// If the item _is_ the tallest item in the row (after being self-sized), then it will stay
/// at its self-sized height until it's no longer the tallest item in the row.
///
/// Note that items with this height mode will resize to match the height of the tallest item in
/// the same row of items, even if the tallest item has a `static` height mode.
case dynamicAndStretchToTallestItemInRow
}
// MARK: Equatable
extension MagazineLayoutItemHeightMode: Equatable {
public static func == (
lhs: MagazineLayoutItemHeightMode,
rhs: MagazineLayoutItemHeightMode)
-> Bool
{
switch (lhs, rhs) {
case (.static(let l), .static(let r)): return l == r
case (.dynamic, .dynamic): return true
case (.dynamicAndStretchToTallestItemInRow, .dynamicAndStretchToTallestItemInRow): return true
default: return false
}
}
}
@@ -0,0 +1,138 @@
// Created by bryankeller on 7/17/17.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
public protocol UICollectionViewDelegateMagazineLayout: UICollectionViewDelegate {
/// Asks the delegate for the size mode of the specified item.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - indexPath: The index path of the item.
///
/// - Returns: The size mode of the specified item.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeModeForItemAt indexPath: IndexPath)
-> MagazineLayoutItemSizeMode
/// Asks the delegate for the visibility mode of the header in the specified section.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section containing the header.
///
/// - Returns: The visibility mode of the header in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
visibilityModeForHeaderInSectionAtIndex index: Int)
-> MagazineLayoutHeaderVisibilityMode
/// Asks the delegate for the visibility mode of the footer in the specified section.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section containing the footer.
///
/// - Returns: The visibility mode of the footer in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
visibilityModeForFooterInSectionAtIndex index: Int)
-> MagazineLayoutFooterVisibilityMode
/// Asks the delegate for the visibility mode of the background in the specified section.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section containing the background.
///
/// - Returns: The visibility mode of the background in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
visibilityModeForBackgroundInSectionAtIndex index: Int)
-> MagazineLayoutBackgroundVisibilityMode
/// Asks the delegate for the horizontal spacing for items in the specified section.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section whose horizontal item spacing is needed.
///
/// - Returns: The horizontal spacing for items in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
horizontalSpacingForItemsInSectionAtIndex index: Int)
-> CGFloat
/// Asks the delegate for the vertical spacing for items in the specified section.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section whose vertical item spacing is needed.
///
/// - Returns: The vertical spacing for items in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
verticalSpacingForElementsInSectionAtIndex index: Int)
-> CGFloat
/// Asks the delegate for the amount by which to inset elements in the specified section.
///
/// Section insets are relative to the content's bounds, which is impacted by the collection
/// view's content inset.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section whose element insets are needed.
///
/// - Returns: The amount by which to inset elements in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetsForSectionAtIndex index: Int)
-> UIEdgeInsets
/// Asks the delegate for the amount by which to inset items in the specified section.
///
/// Item insets are relative to the section's bounds, which is impacted by the section's insets
/// and, transitively, the collection view's content inset.
///
/// - Parameters:
/// - collectionView: The collection view using the layout.
/// - collectionViewLayout: The layout requesting the information.
/// - index: The index of the section whose item insets are needed.
///
/// - Returns: The amount by which to inset items in the specified section.
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetsForItemsInSectionAtIndex index: Int)
-> UIEdgeInsets
}
@@ -0,0 +1,61 @@
// Created by bryankeller on 11/29/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// A collection reusable view that coordinates with `MagazineLayoutCollectionViewLayoutAttributes`
/// to determine how to size itself: with self-sizing, or without self-sizing. Use this class
/// (or subclasses) for displaying header and background supplementary views with `MagazineLayout`.
///
/// Note that this class is very similar to `MagazineLayoutCollectionViewCell`.
///
/// `UIKit` invokes `preferredLayoutAttributesFitting(_:)` with an initial set of layout attributes,
/// giving this reusable view subclass a chance to modify the `size` property of the attributes
/// based on whether or not we want to self-size.
///
/// Subclassing and/or adding additional protocol conformances is encouraged, although modifying
/// the behavior of `preferredLayoutAttributesFitting(_:)` is not recommended.
///
/// This class exists because `MagazineLayout` supports self-sizing supplementary views in just the
/// vertical dimension - a use case that `UICollectionReusableView` does not support out-of-the-box.
open class MagazineLayoutCollectionReusableView: UICollectionReusableView {
override open func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes)
-> UICollectionViewLayoutAttributes
{
guard let attributes = layoutAttributes as? MagazineLayoutCollectionViewLayoutAttributes else {
assertionFailure("`layoutAttributes` must be an instance of `MagazineLayoutCollectionViewLayoutAttributes`")
return super.preferredLayoutAttributesFitting(layoutAttributes)
}
let size: CGSize
if attributes.shouldVerticallySelfSize {
// Self-sizing is required in the vertical dimension.
size = super.systemLayoutSizeFitting(
layoutAttributes.size,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
} else {
// No self-sizing is required; respect whatever size the layout determined.
size = layoutAttributes.size
}
layoutAttributes.size = size
return layoutAttributes
}
}
@@ -0,0 +1,88 @@
// Created by bryankeller on 11/29/18.
// Copyright © 2018 Airbnb, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
/// A cell that coordinates with `MagazineLayoutCollectionViewLayoutAttributes` to determine how to
/// size itself: with self-sizing, or without self-sizing. Use this class (or subclasses) for
/// displaying cells with `MagazineLayout`.
///
/// Note that this class is very similar to `MagazineLayoutCollectionReusableView`.
///
/// `UIKit` invokes `preferredLayoutAttributesFitting(_:)` with an initial set of layout attributes,
/// giving this cell subclass a chance to modify the `size` property of the attributes based on
/// whether or not we want to self-size.
///
/// Subclassing and/or adding additional protocol conformances is encouraged, although modifying
/// the behavior of `preferredLayoutAttributesFitting(_:)` is not recommended.
///
/// This class exists because `MagazineLayout` supports self-sizing in just the vertical dimension -
/// a use case that `UICollectionViewCell` does not support out-of-the-box.
///
/// As of iOS 12, `UICollectionReusableView` is tightly coupled with `UICollectionViewFlowLayout`'s
/// private `_estimatesSizes` property, which is used to determine how to size cells displayed in a
/// `UICollectionViewFlowLayout`. If `_estimatesSizes` is `true`, then `UICollectionReusableView`
/// will self-size in both the horizontal and vertical dimensions. If it is `false`, no self-sizing
/// will occur. In short, `UICollectionReusableView` is only optimized to work correctly with
/// Apple's own layout.
open class MagazineLayoutCollectionViewCell: UICollectionViewCell {
override open func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes)
-> UICollectionViewLayoutAttributes
{
guard let attributes = layoutAttributes as? MagazineLayoutCollectionViewLayoutAttributes else {
assertionFailure("`layoutAttributes` must be an instance of `MagazineLayoutCollectionViewLayoutAttributes`")
return super.preferredLayoutAttributesFitting(layoutAttributes)
}
// In some cases, `contentView`'s required width and height constraints
// (created from its auto-resizing mask) will not have the correct constants before invoking
// `systemLayoutSizeFitting(...)`, causing the cell to size incorrectly. This seems to be a
// UIKit bug.
// https://openradar.appspot.com/radar?id=5025850143539200
// The issue seems most common when the collection view's bounds change (on rotation).
// We correct for this by updating `contentView.bounds`, which updates the constants used by the
// width and height constraints created by the `contentView`'s auto-resizing mask.
if contentView.bounds.width != layoutAttributes.size.width {
contentView.bounds.size.width = layoutAttributes.size.width
}
if
!attributes.shouldVerticallySelfSize &&
contentView.bounds.height != layoutAttributes.size.height
{
contentView.bounds.size.height = layoutAttributes.size.height
}
let size: CGSize
if attributes.shouldVerticallySelfSize {
// Self-sizing is required in the vertical dimension.
size = super.systemLayoutSizeFitting(
layoutAttributes.size,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
} else {
// No self-sizing is required; respect whatever size the layout determined.
size = layoutAttributes.size
}
layoutAttributes.size = size
return layoutAttributes
}
}