自适应宽度历史记录标签
实现: 自定义 UICollectionViewFlowLayout
//
// AlignedCollectionViewFlowLayout.swift
//
// Created by Mischa Hildebrand on 12/04/2017.
// Copyright © 2017 Mischa Hildebrand.
//
// Licensed under the terms of the MIT license:
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
import UIKit
// MARK: - 🦆 Type definitions
/// An abstract protocol that defines an alignment.
protocol Alignment {}
/// Defines an alignment for UI elements.
public enum HorizontalAlignment: Alignment {
case left
case justified
case right
}
/// Defines a vertical alignment for UI elements.
public enum VerticalAlignment: Alignment {
case top
case center
case bottom
}
/// Describes an axis with respect to which items can be aligned.
private struct AlignmentAxis<A: Alignment> {
/// Determines how items are aligned relative to the axis.
let alignment: A
/// Defines the position of the axis.
/// * If the `Alignment` is horizontal, the alignment axis is vertical and this is the position on the `x` axis.
/// * If the `Alignment` is vertical, the alignment axis is horizontal and this is the position on the `y` axis.
let position: CGFloat
}
/// A `UICollectionViewFlowLayout` subclass that gives you control
/// over the horizontal and vertical alignment of the cells.
/// You can use it to align the cells like words in a left- or right-aligned text
/// and you can specify how the cells are vertically aligned in their row.
public class AlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
// MARK: - 🔶 Properties
/// Determines how the cells are horizontally aligned in a row.
/// - Note: The default is `.justified`.
public var horizontalAlignment: HorizontalAlignment = .justified
/// Determines how the cells are vertically aligned in a row.
/// - Note: The default is `.center`.
public var verticalAlignment: VerticalAlignment = .center
/// The vertical axis with respect to which the cells are horizontally aligned.
/// For a `justified` alignment the alignment axis is not defined and this value is `nil`.
fileprivate var alignmentAxis: AlignmentAxis<HorizontalAlignment>? {
switch horizontalAlignment {
case .left:
return AlignmentAxis(alignment: HorizontalAlignment.left, position: sectionInset.left)
case .right:
guard let collectionViewWidth = collectionView?.frame.size.width else {
return nil
}
return AlignmentAxis(alignment: HorizontalAlignment.right, position: collectionViewWidth - sectionInset.right)
default:
return nil
}
}
/// The width of the area inside the collection view that can be filled with cells.
private var contentWidth: CGFloat? {
guard let collectionViewWidth = collectionView?.frame.size.width else {
return nil
}
return collectionViewWidth - sectionInset.left - sectionInset.right
}
// MARK: - 👶 Initialization
/// The designated initializer.
///
/// - Parameters:
/// - horizontalAlignment: Specifies how the cells are horizontally aligned in a row. --
/// (Default: `.justified`)
/// - verticalAlignment: Specified how the cells are vertically aligned in a row. --
/// (Default: `.center`)
public init(horizontalAlignment: HorizontalAlignment = .justified, verticalAlignment: VerticalAlignment = .center) {
super.init()
self.horizontalAlignment = horizontalAlignment
self.verticalAlignment = verticalAlignment
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// MARK: - 🅾️ Overrides
override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// 💡 IDEA:
// The approach for computing a cell's frame is to create a rectangle that covers the current line.
// Then we check if the preceding cell's frame intersects with this rectangle.
// If it does, the current item is not the first item in the line. Otherwise it is.
// (Vice-versa for right-aligned cells.)
//
// +---------+----------------------------------------------------------------+---------+
// | | | |
// | | +------------+ | |
// | | | | | |
// | section |- - -|- - - - - - |- - - - +---------------------+ - - - - - - -| section |
// | inset | |intersection| | | line rect | inset |
// | |- - -|- - - - - - |- - - - +---------------------+ - - - - - - -| |
// | (left) | | | current item | (right) |
// | | +------------+ | |
// | | previous item | |
// +---------+----------------------------------------------------------------+---------+
//
// ℹ️ We need this rather complicated approach because the first item in a line
// is not always left-aligned and the last item in a line is not always right-aligned:
// If there is only one item in a line UICollectionViewFlowLayout will center it.
// We may not change the original layout attributes or UICollectionViewFlowLayout might complain.
guard let layoutAttributes = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else {
return nil
}
// For a justified layout there's nothing to do here
// as UICollectionViewFlowLayout justifies the items in a line by default.
if horizontalAlignment != .justified {
layoutAttributes.alignHorizontally(collectionViewLayout: self)
}
// For a vertically centered layout there's nothing to do here
// as UICollectionViewFlowLayout center-aligns the items in a line by default.
if verticalAlignment != .center {
layoutAttributes.alignVertically(collectionViewLayout: self)
}
return layoutAttributes
}
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// We may not change the original layout attributes or UICollectionViewFlowLayout might complain.
let layoutAttributesObjects = copy(super.layoutAttributesForElements(in: rect))
layoutAttributesObjects?.forEach({ (layoutAttributes) in
setFrame(forLayoutAttributes: layoutAttributes)
})
return layoutAttributesObjects
}
// MARK: - 👷 Private layout helpers
/// Sets the frame for the passed layout attributes object by calling the `layoutAttributesForItem(at:)` function.
private func setFrame(forLayoutAttributes layoutAttributes: UICollectionViewLayoutAttributes) {
if layoutAttributes.representedElementCategory == .cell { // Do not modify header views etc.
let indexPath = layoutAttributes.indexPath
if let newFrame = layoutAttributesForItem(at: indexPath)?.frame {
layoutAttributes.frame = newFrame
}
}
}
/// A function to access the `super` implementation of `layoutAttributesForItem(at:)` externally.
///
/// - Parameter indexPath: The index path of the item for which to return the layout attributes.
/// - Returns: The unmodified layout attributes for the item at the specified index path
/// as computed by `UICollectionViewFlowLayout`.
fileprivate func originalLayoutAttribute(forItemAt indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return super.layoutAttributesForItem(at: indexPath)
}
/// Determines if the `firstItemAttributes`' frame is in the same line
/// as the `secondItemAttributes`' frame.
///
/// - Parameters:
/// - firstItemAttributes: The first layout attributes object to be compared.
/// - secondItemAttributes: The second layout attributes object to be compared.
/// - Returns: `true` if the frames of the two layout attributes are in the same line, else `false`.
/// `false` is also returned when the layout's `collectionView` property is `nil`.
fileprivate func isFrame(for firstItemAttributes: UICollectionViewLayoutAttributes, inSameLineAsFrameFor secondItemAttributes: UICollectionViewLayoutAttributes) -> Bool {
guard let lineWidth = contentWidth else {
return false
}
let firstItemFrame = firstItemAttributes.frame
let lineFrame = CGRect(x: sectionInset.left,
y: firstItemFrame.origin.y,
width: lineWidth,
height: firstItemFrame.size.height)
return lineFrame.intersects(secondItemAttributes.frame)
}
/// Determines the layout attributes objects for all items displayed in the same line as the item
/// represented by the passed `layoutAttributes` object.
///
/// - Parameter layoutAttributes: The layout attributed that represents the reference item.
/// - Returns: The layout attributes objects representing all other items in the same line.
/// The passed `layoutAttributes` object itself is always contained in the returned array.
fileprivate func layoutAttributes(forItemsInLineWith layoutAttributes: UICollectionViewLayoutAttributes) -> [UICollectionViewLayoutAttributes] {
guard let lineWidth = contentWidth else {
return [layoutAttributes]
}
var lineFrame = layoutAttributes.frame
lineFrame.origin.x = sectionInset.left
lineFrame.size.width = lineWidth
return super.layoutAttributesForElements(in: lineFrame) ?? []
}
/// Copmutes the alignment axis with which to align the items represented by the `layoutAttributes` objects vertically.
///
/// - Parameter layoutAttributes: The layout attributes objects to be vertically aligned.
/// - Returns: The axis with respect to which the layout attributes can be aligned
/// or `nil` if the `layoutAttributes` array is empty.
private func verticalAlignmentAxisForLine(with layoutAttributes: [UICollectionViewLayoutAttributes]) -> AlignmentAxis<VerticalAlignment>? {
guard let firstAttribute = layoutAttributes.first else {
return nil
}
switch verticalAlignment {
case .top:
let minY = layoutAttributes.reduce(CGFloat.greatestFiniteMagnitude) { min($0, $1.frame.minY) }
return AlignmentAxis(alignment: .top, position: minY)
case .bottom:
let maxY = layoutAttributes.reduce(0) { max($0, $1.frame.maxY) }
return AlignmentAxis(alignment: .bottom, position: maxY)
default:
let centerY = firstAttribute.center.y
return AlignmentAxis(alignment: .center, position: centerY)
}
}
/// Computes the axis with which to align the item represented by the `currentLayoutAttributes` vertically.
///
/// - Parameter currentLayoutAttributes: The layout attributes representing the item to be vertically aligned.
/// - Returns: The axis with respect to which the item can be aligned.
fileprivate func verticalAlignmentAxis(for currentLayoutAttributes: UICollectionViewLayoutAttributes) -> AlignmentAxis<VerticalAlignment> {
let layoutAttributesInLine = layoutAttributes(forItemsInLineWith: currentLayoutAttributes)
// It's okay to force-unwrap here because we pass a non-empty array.
return verticalAlignmentAxisForLine(with: layoutAttributesInLine)!
}
/// Creates a deep copy of the passed array by copying all its items.
///
/// - Parameter layoutAttributesArray: The array to be copied.
/// - Returns: A deep copy of the passed array.
private func copy(_ layoutAttributesArray: [UICollectionViewLayoutAttributes]?) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributesArray?.map{ $0.copy() } as? [UICollectionViewLayoutAttributes]
}
}
// MARK: - 👷 Layout attributes helpers
fileprivate extension UICollectionViewLayoutAttributes {
private var currentSection: Int {
return indexPath.section
}
private var currentItem: Int {
return indexPath.item
}
/// The index path for the item preceding the item represented by this layout attributes object.
private var precedingIndexPath: IndexPath {
return IndexPath(item: currentItem - 1, section: currentSection)
}
/// The index path for the item following the item represented by this layout attributes object.
private var followingIndexPath: IndexPath {
return IndexPath(item: currentItem + 1, section: currentSection)
}
/// Checks if the item represetend by this layout attributes object is the first item in the line.
///
/// - Parameter collectionViewLayout: The layout for which to perform the check.
/// - Returns: `true` if the represented item is the first item in the line, else `false`.
func isRepresentingFirstItemInLine(collectionViewLayout: AlignedCollectionViewFlowLayout) -> Bool {
if currentItem <= 0 {
return true
}
else {
if let layoutAttributesForPrecedingItem = collectionViewLayout.originalLayoutAttribute(forItemAt: precedingIndexPath) {
return !collectionViewLayout.isFrame(for: self, inSameLineAsFrameFor: layoutAttributesForPrecedingItem)
}
else {
return true
}
}
}
/// Checks if the item represetend by this layout attributes object is the last item in the line.
///
/// - Parameter collectionViewLayout: The layout for which to perform the check.
/// - Returns: `true` if the represented item is the last item in the line, else `false`.
func isRepresentingLastItemInLine(collectionViewLayout: AlignedCollectionViewFlowLayout) -> Bool {
guard let itemCount = collectionViewLayout.collectionView?.numberOfItems(inSection: currentSection) else {
return false
}
if currentItem >= itemCount - 1 {
return true
}
else {
if let layoutAttributesForFollowingItem = collectionViewLayout.originalLayoutAttribute(forItemAt: followingIndexPath) {
return !collectionViewLayout.isFrame(for: self, inSameLineAsFrameFor: layoutAttributesForFollowingItem)
}
else {
return true
}
}
}
/// Moves the layout attributes object's frame so that it is aligned horizontally with the alignment axis.
func align(toAlignmentAxis alignmentAxis: AlignmentAxis<HorizontalAlignment>) {
switch alignmentAxis.alignment {
case .left:
frame.origin.x = alignmentAxis.position
case .right:
frame.origin.x = alignmentAxis.position - frame.size.width
default:
break
}
}
/// Moves the layout attributes object's frame so that it is aligned vertically with the alignment axis.
func align(toAlignmentAxis alignmentAxis: AlignmentAxis<VerticalAlignment>) {
switch alignmentAxis.alignment {
case .top:
frame.origin.y = alignmentAxis.position
case .bottom:
frame.origin.y = alignmentAxis.position - frame.size.height
default:
center.y = alignmentAxis.position
}
}
/// Positions the frame right of the preceding item's frame, leaving a spacing between the frames
/// as defined by the collection view layout's `minimumInteritemSpacing`.
///
/// - Parameter collectionViewLayout: The layout on which to perfom the calculations.
private func alignToPrecedingItem(collectionViewLayout: AlignedCollectionViewFlowLayout) {
let itemSpacing = collectionViewLayout.minimumInteritemSpacing
if let precedingItemAttributes = collectionViewLayout.layoutAttributesForItem(at: precedingIndexPath) {
frame.origin.x = precedingItemAttributes.frame.maxX + itemSpacing
}
}
/// Positions the frame left of the following item's frame, leaving a spacing between the frames
/// as defined by the collection view layout's `minimumInteritemSpacing`.
///
/// - Parameter collectionViewLayout: The layout on which to perfom the calculations.
private func alignToFollowingItem(collectionViewLayout: AlignedCollectionViewFlowLayout) {
let itemSpacing = collectionViewLayout.minimumInteritemSpacing
if let followingItemAttributes = collectionViewLayout.layoutAttributesForItem(at: followingIndexPath) {
frame.origin.x = followingItemAttributes.frame.minX - itemSpacing - frame.size.width
}
}
/// Aligns the frame horizontally as specified by the collection view layout's `horizontalAlignment`.
///
/// - Parameters:
/// - collectionViewLayout: The layout providing the alignment information.
func alignHorizontally(collectionViewLayout: AlignedCollectionViewFlowLayout) {
guard let alignmentAxis = collectionViewLayout.alignmentAxis else {
return
}
switch collectionViewLayout.horizontalAlignment {
case .left:
if isRepresentingFirstItemInLine(collectionViewLayout: collectionViewLayout) {
align(toAlignmentAxis: alignmentAxis)
} else {
alignToPrecedingItem(collectionViewLayout: collectionViewLayout)
}
case .right:
if isRepresentingLastItemInLine(collectionViewLayout: collectionViewLayout) {
align(toAlignmentAxis: alignmentAxis)
} else {
alignToFollowingItem(collectionViewLayout: collectionViewLayout)
}
default:
return
}
}
/// Aligns the frame vertically as specified by the collection view layout's `verticalAlignment`.
///
/// - Parameter collectionViewLayout: The layout providing the alignment information.
func alignVertically(collectionViewLayout: AlignedCollectionViewFlowLayout) {
let alignmentAxis = collectionViewLayout.verticalAlignmentAxis(for: self)
align(toAlignmentAxis: alignmentAxis)
}
}