import UIKit public enum LNCyclePagerTransformLayoutType { case normal case linear case coverflow } public enum LNCyclePagerScrollDirection { case horizontal case vertical } public final class LNCyclePagerViewLayout { weak var pageView: UIView? public var itemSize: CGSize = .zero public var itemSpacing: CGFloat = 0 public var sectionInset: UIEdgeInsets = .zero public var layoutType: LNCyclePagerTransformLayoutType = .normal public var scrollDirection: LNCyclePagerScrollDirection = .horizontal public var minimumScale: CGFloat = 0.8 public var minimumAlpha: CGFloat = 1.0 public var maximumAngle: CGFloat = 0.2 public var isInfiniteLoop = true public var rateOfChange: CGFloat = 0.4 public var adjustSpacingWhenScrolling = true public var itemVerticalCenter = true public var itemHorizontalCenter = false public init() {} public var onlyOneSectionInset: UIEdgeInsets { if scrollDirection == .horizontal { let leftSpace = (pageView != nil && !isInfiniteLoop && itemHorizontalCenter) ? ((pageView!.bounds.width - itemSize.width) * 0.5) : sectionInset.left let rightSpace = (pageView != nil && !isInfiniteLoop && itemHorizontalCenter) ? ((pageView!.bounds.width - itemSize.width) * 0.5) : sectionInset.right if itemVerticalCenter, let pageView { let vertical = (pageView.bounds.height - itemSize.height) * 0.5 return UIEdgeInsets(top: vertical, left: leftSpace, bottom: vertical, right: rightSpace) } return UIEdgeInsets(top: sectionInset.top, left: leftSpace, bottom: sectionInset.bottom, right: rightSpace) } let topSpace = (pageView != nil && !isInfiniteLoop) ? ((pageView!.bounds.height - itemSize.height) * 0.5) : sectionInset.top let bottomSpace = (pageView != nil && !isInfiniteLoop) ? ((pageView!.bounds.height - itemSize.height) * 0.5) : sectionInset.bottom let horizontal = pageView.map { ($0.bounds.width - itemSize.width) * 0.5 } ?? sectionInset.left return UIEdgeInsets(top: topSpace, left: horizontal, bottom: bottomSpace, right: horizontal) } public var firstSectionInset: UIEdgeInsets { if scrollDirection == .horizontal { if itemVerticalCenter, let pageView { let vertical = (pageView.bounds.height - itemSize.height) * 0.5 return UIEdgeInsets(top: vertical, left: sectionInset.left, bottom: vertical, right: itemSpacing) } return UIEdgeInsets(top: sectionInset.top, left: sectionInset.left, bottom: sectionInset.bottom, right: itemSpacing) } let horizontal = pageView.map { ($0.bounds.width - itemSize.width) * 0.5 } ?? sectionInset.left return UIEdgeInsets(top: sectionInset.top, left: horizontal, bottom: itemSpacing, right: horizontal) } public var lastSectionInset: UIEdgeInsets { if scrollDirection == .horizontal { if itemVerticalCenter, let pageView { let vertical = (pageView.bounds.height - itemSize.height) * 0.5 return UIEdgeInsets(top: vertical, left: 0, bottom: vertical, right: sectionInset.right) } return UIEdgeInsets(top: sectionInset.top, left: 0, bottom: sectionInset.bottom, right: sectionInset.right) } let horizontal = pageView.map { ($0.bounds.width - itemSize.width) * 0.5 } ?? sectionInset.left return UIEdgeInsets(top: 0, left: horizontal, bottom: sectionInset.bottom, right: horizontal) } public var middleSectionInset: UIEdgeInsets { if scrollDirection == .horizontal { if itemVerticalCenter, let pageView { let vertical = (pageView.bounds.height - itemSize.height) * 0.5 return UIEdgeInsets(top: vertical, left: 0, bottom: vertical, right: itemSpacing) } return sectionInset } let horizontal = pageView.map { ($0.bounds.width - itemSize.width) * 0.5 } ?? sectionInset.left return UIEdgeInsets(top: 0, left: horizontal, bottom: itemSpacing, right: horizontal) } } public protocol LNCyclePagerTransformLayoutDelegate: AnyObject { func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, initialize attributes: UICollectionViewLayoutAttributes) func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, apply attributes: UICollectionViewLayoutAttributes) } public final class LNCyclePagerTransformLayout: UICollectionViewFlowLayout { public weak var transformDelegate: LNCyclePagerTransformLayoutDelegate? public var layoutConfig: LNCyclePagerViewLayout? { didSet { guard let layoutConfig else { return } layoutConfig.pageView = collectionView itemSize = layoutConfig.itemSize minimumInteritemSpacing = layoutConfig.itemSpacing minimumLineSpacing = layoutConfig.itemSpacing scrollDirection = layoutConfig.scrollDirection == .horizontal ? .horizontal : .vertical invalidateLayout() } } private enum ItemDirection { case leading case center case trailing } public override init() { super.init() scrollDirection = .horizontal } public required init?(coder: NSCoder) { super.init(coder: coder) scrollDirection = .horizontal } public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { guard let layoutConfig else { return super.shouldInvalidateLayout(forBoundsChange: newBounds) } return layoutConfig.layoutType == .normal ? super.shouldInvalidateLayout(forBoundsChange: newBounds) : true } public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let attrs = super.layoutAttributesForElements(in: rect), let layoutConfig else { return super.layoutAttributesForElements(in: rect) } if transformDelegate == nil, layoutConfig.layoutType == .normal { return attrs } let copied = attrs.compactMap { $0.copy() as? UICollectionViewLayoutAttributes } let visibleRect = CGRect(origin: collectionView?.contentOffset ?? .zero, size: collectionView?.bounds.size ?? .zero) for attr in copied where attr.representedElementCategory == .cell { guard visibleRect.intersects(attr.frame) else { continue } if let transformDelegate { transformDelegate.pagerTransformLayout(self, apply: attr) } else { applyTransform(to: attr, type: layoutConfig.layoutType) } } return copied } public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard let attr = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes else { return nil } guard let layoutConfig else { return attr } if let transformDelegate { transformDelegate.pagerTransformLayout(self, initialize: attr) } else if layoutConfig.layoutType != .normal { initializeTransform(to: attr, type: layoutConfig.layoutType) } return attr } private func itemDirection(for center: CGPoint) -> ItemDirection { guard let collectionView else { return .center } if scrollDirection == .horizontal { let contentCenterX = collectionView.contentOffset.x + collectionView.bounds.width * 0.5 if abs(center.x - contentCenterX) < 0.5 { return .center } return center.x < contentCenterX ? .leading : .trailing } let contentCenterY = collectionView.contentOffset.y + collectionView.bounds.height * 0.5 if abs(center.y - contentCenterY) < 0.5 { return .center } return center.y < contentCenterY ? .leading : .trailing } private func initializeTransform(to attributes: UICollectionViewLayoutAttributes, type: LNCyclePagerTransformLayoutType) { guard let layoutConfig else { return } switch type { case .linear: applyLinear(to: attributes, scale: layoutConfig.minimumScale, alpha: layoutConfig.minimumAlpha) case .coverflow: applyCoverflow(to: attributes, angle: layoutConfig.maximumAngle, alpha: layoutConfig.minimumAlpha) case .normal: break } } private func applyTransform(to attributes: UICollectionViewLayoutAttributes, type: LNCyclePagerTransformLayoutType) { switch type { case .linear: applyLinear(to: attributes) case .coverflow: applyCoverflow(to: attributes) case .normal: break } } private func applyLinear(to attributes: UICollectionViewLayoutAttributes) { guard let layoutConfig, let collectionView else { return } if scrollDirection == .horizontal { let width = collectionView.bounds.width guard width > 0 else { return } let centerX = collectionView.contentOffset.x + width * 0.5 let delta = abs(attributes.center.x - centerX) let scale = max(1 - delta / width * layoutConfig.rateOfChange, layoutConfig.minimumScale) let alpha = max(1 - delta / width, layoutConfig.minimumAlpha) applyLinear(to: attributes, scale: scale, alpha: alpha) return } let height = collectionView.bounds.height guard height > 0 else { return } let centerY = collectionView.contentOffset.y + height * 0.5 let delta = abs(attributes.center.y - centerY) let scale = max(1 - delta / height * layoutConfig.rateOfChange, layoutConfig.minimumScale) let alpha = max(1 - delta / height, layoutConfig.minimumAlpha) applyLinear(to: attributes, scale: scale, alpha: alpha) } private func applyLinear(to attributes: UICollectionViewLayoutAttributes, scale: CGFloat, alpha: CGFloat) { guard let layoutConfig else { return } var resolvedScale = scale var resolvedAlpha = alpha var transform = CGAffineTransform(scaleX: resolvedScale, y: resolvedScale) if layoutConfig.adjustSpacingWhenScrolling { let direction = itemDirection(for: attributes.center) let length = scrollDirection == .horizontal ? attributes.size.width : attributes.size.height var translate: CGFloat = 0 switch direction { case .leading: translate = 1.15 * length * (1 - resolvedScale) * 0.5 case .trailing: translate = -1.15 * length * (1 - resolvedScale) * 0.5 case .center: resolvedScale = 1 resolvedAlpha = 1 transform = CGAffineTransform.identity } if direction != .center { if scrollDirection == .horizontal { transform = transform.translatedBy(x: translate, y: 0) } else { transform = transform.translatedBy(x: 0, y: translate) } } } attributes.transform = transform attributes.alpha = resolvedAlpha } private func applyCoverflow(to attributes: UICollectionViewLayoutAttributes) { guard let layoutConfig, let collectionView else { return } if scrollDirection == .horizontal { let width = collectionView.bounds.width guard width > 0 else { return } let centerX = collectionView.contentOffset.x + width * 0.5 let delta = abs(attributes.center.x - centerX) let angle = min(delta / width * (1 - layoutConfig.rateOfChange), layoutConfig.maximumAngle) let alpha = max(1 - delta / width, layoutConfig.minimumAlpha) applyCoverflow(to: attributes, angle: angle, alpha: alpha) return } let height = collectionView.bounds.height guard height > 0 else { return } let centerY = collectionView.contentOffset.y + height * 0.5 let delta = abs(attributes.center.y - centerY) let angle = min(delta / height * (1 - layoutConfig.rateOfChange), layoutConfig.maximumAngle) let alpha = max(1 - delta / height, layoutConfig.minimumAlpha) applyCoverflow(to: attributes, angle: angle, alpha: alpha) } private func applyCoverflow(to attributes: UICollectionViewLayoutAttributes, angle: CGFloat, alpha: CGFloat) { guard let layoutConfig else { return } var resolvedAngle = angle var resolvedAlpha = alpha let direction = itemDirection(for: attributes.center) var transform3D = CATransform3DIdentity transform3D.m34 = -0.002 var translate: CGFloat = 0 switch direction { case .leading: let length = scrollDirection == .horizontal ? attributes.size.width : attributes.size.height translate = (1 - cos(resolvedAngle * 1.2 * .pi)) * length case .trailing: let length = scrollDirection == .horizontal ? attributes.size.width : attributes.size.height translate = -(1 - cos(resolvedAngle * 1.2 * .pi)) * length resolvedAngle = -resolvedAngle case .center: resolvedAngle = 0 resolvedAlpha = 1 } if scrollDirection == .horizontal { transform3D = CATransform3DRotate(transform3D, .pi * resolvedAngle, 0, 1, 0) } else { transform3D = CATransform3DRotate(transform3D, .pi * (-resolvedAngle), 1, 0, 0) } if layoutConfig.adjustSpacingWhenScrolling { if scrollDirection == .horizontal { transform3D = CATransform3DTranslate(transform3D, translate, 0, 0) } else { transform3D = CATransform3DTranslate(transform3D, 0, translate, 0) } } attributes.transform3D = transform3D attributes.alpha = resolvedAlpha } }