diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9981cb6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. +import PackageDescription + +let package = Package( + name: "UPCarouselFlowLayout", + platforms: [ + .iOS(.v11), + .watchOS(.v6) + ], + products: [ + .library( + name: "UPCarouselFlowLayout", + type: .static, + targets: ["UPCarouselFlowLayout"] + ) + ], + dependencies: [], + targets: [ + .target( + name: "UPCarouselFlowLayout" + ) + ] +) \ No newline at end of file diff --git a/UPCarouselFlowLayout/Info.plist b/Sources/UPCarouselFlowLayout/Info.plist similarity index 100% rename from UPCarouselFlowLayout/Info.plist rename to Sources/UPCarouselFlowLayout/Info.plist diff --git a/UPCarouselFlowLayout/UPCarouselFlowLayout.h b/Sources/UPCarouselFlowLayout/UPCarouselFlowLayout.h similarity index 100% rename from UPCarouselFlowLayout/UPCarouselFlowLayout.h rename to Sources/UPCarouselFlowLayout/UPCarouselFlowLayout.h diff --git a/Sources/UPCarouselFlowLayout/UPCarouselFlowLayout.swift b/Sources/UPCarouselFlowLayout/UPCarouselFlowLayout.swift new file mode 100644 index 0000000..efed537 --- /dev/null +++ b/Sources/UPCarouselFlowLayout/UPCarouselFlowLayout.swift @@ -0,0 +1,187 @@ +// +// UPCarouselFlowLayout.swift +// UPCarouselFlowLayoutDemo +// +// Created by Paul Ulric on 23/06/2016. +// Copyright © 2016 Paul Ulric. All rights reserved. +// + +import UIKit + +// MARK: - Enums + +public enum CarouselFlowLayoutSpacingMode { + case fixed(spacing: CGFloat) + case overlap(visibleOffset: CGFloat) +} + +// MARK: - Class + +open class CarouselFlowLayout: UICollectionViewFlowLayout { + + // MARK: - Properties + + // Inspectable properties + + @IBInspectable open var sideItemScale: CGFloat = 0.8 + @IBInspectable open var sideItemAlpha: CGFloat = 0.6 + @IBInspectable open var sideItemShift: CGFloat = 0.0 + + // Open properties + + open var spacingMode = CarouselFlowLayoutSpacingMode.fixed(spacing: 8) + + // File private properties + + fileprivate var state = LayoutState(size: .zero, direction: .horizontal) + + // MARK: - Methods + + // Open methods + + override open func prepare() { + super.prepare() + let currentState = LayoutState( + size: collectionView?.bounds.size ?? .init(), + direction: scrollDirection + ) + + if !self.state.isEqual(currentState) { + self.setupCollectionView() + self.updateLayout() + self.state = currentState + } + } + + override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + return true + } + + override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard + let superAttributes = super.layoutAttributesForElements(in: rect), + let attributes = NSArray( + array: superAttributes, + copyItems: true + ) as? [UICollectionViewLayoutAttributes] + else { return nil } + + return attributes.map({ self.transformLayoutAttributes($0) }) + } + + override open func targetContentOffset( + forProposedContentOffset proposedContentOffset: CGPoint, + withScrollingVelocity velocity: CGPoint + ) -> CGPoint { + guard + let collectionView = collectionView, !collectionView.isPagingEnabled, + let layoutAttributes = layoutAttributesForElements(in: collectionView.bounds) + else { + return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) + } + + let isHorizontal = scrollDirection == .horizontal + + let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2 + let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide + + var targetContentOffset: CGPoint + if isHorizontal { + let closest = layoutAttributes.sorted { + abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) + }.first ?? .init() + + targetContentOffset = .init( + x: floor(closest.center.x - midSide), + y: proposedContentOffset.y + ) + + } else { + let closest = layoutAttributes.sorted { + abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) + }.first ?? .init() + + targetContentOffset = .init( + x: proposedContentOffset.x, + y: floor(closest.center.y - midSide) + ) + } + + return targetContentOffset + } + + // File private methods + + fileprivate func setupCollectionView() { + guard let collectionView = collectionView else { return } + if collectionView.decelerationRate != UIScrollView.DecelerationRate.fast { + collectionView.decelerationRate = UIScrollView.DecelerationRate.fast + } + } + + fileprivate func updateLayout() { + guard let collectionView = collectionView else { return } + + let collectionSize = collectionView.bounds.size + let isHorizontal = scrollDirection == .horizontal + + let yInset = (collectionSize.height - itemSize.height) / 2 + let xInset = (collectionSize.width - itemSize.width) / 2 + sectionInset = .init(top: yInset, left: xInset, bottom: yInset, right: xInset) + + let side = isHorizontal ? itemSize.width : itemSize.height + let scaledItemOffset = (side - side*sideItemScale) / 2 + + switch self.spacingMode { + case .fixed(let spacing): + minimumLineSpacing = spacing - scaledItemOffset + case .overlap(let visibleOffset): + let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset + let inset = isHorizontal ? xInset : yInset + minimumLineSpacing = inset - fullSizeSideItemOverlap + } + } + + fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + + guard let collectionView = collectionView else { return attributes } + + let isHorizontal = scrollDirection == .horizontal + let collectionCenter = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2 + let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y + let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset + + let maxDistance = (isHorizontal ? itemSize.width : itemSize.height) + minimumLineSpacing + let distance = min(abs(collectionCenter - normalizedCenter), maxDistance) + let ratio = (maxDistance - distance)/maxDistance + + let alpha = ratio * (1 - sideItemAlpha) + sideItemAlpha + let scale = ratio * (1 - sideItemScale) + sideItemScale + let shift = (1 - ratio) * sideItemShift + + attributes.alpha = alpha + attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1) + attributes.zIndex = Int(alpha * 10) + + if isHorizontal { + attributes.center.y += shift + } else { + attributes.center.x += shift + } + + return attributes + } +} + +// MARK: - Structs + +extension CarouselFlowLayout { + fileprivate struct LayoutState { + var size: CGSize + var direction: UICollectionView.ScrollDirection + + func isEqual(_ otherState: LayoutState) -> Bool { + return self.size.equalTo(otherState.size) && self.direction == otherState.direction + } + } +} diff --git a/UPCarouselFlowLayout.podspec b/UPCarouselFlowLayout.podspec index e883556..66b81a7 100644 --- a/UPCarouselFlowLayout.podspec +++ b/UPCarouselFlowLayout.podspec @@ -13,6 +13,6 @@ Pod::Spec.new do |s| s.ios.deployment_target = '8.1' s.swift_version = '4.2' - s.source_files = 'UPCarouselFlowLayout/**/*.{h,swift}' + s.source_files = 'Sources/UPCarouselFlowLayout/**/*.{h,swift}' end diff --git a/UPCarouselFlowLayout/UPCarouselFlowLayout.swift b/UPCarouselFlowLayout/UPCarouselFlowLayout.swift deleted file mode 100644 index a4a5da1..0000000 --- a/UPCarouselFlowLayout/UPCarouselFlowLayout.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// UPCarouselFlowLayout.swift -// UPCarouselFlowLayoutDemo -// -// Created by Paul Ulric on 23/06/2016. -// Copyright © 2016 Paul Ulric. All rights reserved. -// - -import UIKit - - -public enum UPCarouselFlowLayoutSpacingMode { - case fixed(spacing: CGFloat) - case overlap(visibleOffset: CGFloat) -} - - -open class UPCarouselFlowLayout: UICollectionViewFlowLayout { - - fileprivate struct LayoutState { - var size: CGSize - var direction: UICollectionView.ScrollDirection - func isEqual(_ otherState: LayoutState) -> Bool { - return self.size.equalTo(otherState.size) && self.direction == otherState.direction - } - } - - @IBInspectable open var sideItemScale: CGFloat = 0.6 - @IBInspectable open var sideItemAlpha: CGFloat = 0.6 - @IBInspectable open var sideItemShift: CGFloat = 0.0 - open var spacingMode = UPCarouselFlowLayoutSpacingMode.fixed(spacing: 40) - - fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal) - - - override open func prepare() { - super.prepare() - let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection) - - if !self.state.isEqual(currentState) { - self.setupCollectionView() - self.updateLayout() - self.state = currentState - } - } - - fileprivate func setupCollectionView() { - guard let collectionView = self.collectionView else { return } - if collectionView.decelerationRate != UIScrollView.DecelerationRate.fast { - collectionView.decelerationRate = UIScrollView.DecelerationRate.fast - } - } - - fileprivate func updateLayout() { - guard let collectionView = self.collectionView else { return } - - let collectionSize = collectionView.bounds.size - let isHorizontal = (self.scrollDirection == .horizontal) - - let yInset = (collectionSize.height - self.itemSize.height) / 2 - let xInset = (collectionSize.width - self.itemSize.width) / 2 - self.sectionInset = UIEdgeInsets.init(top: yInset, left: xInset, bottom: yInset, right: xInset) - - let side = isHorizontal ? self.itemSize.width : self.itemSize.height - let scaledItemOffset = (side - side*self.sideItemScale) / 2 - switch self.spacingMode { - case .fixed(let spacing): - self.minimumLineSpacing = spacing - scaledItemOffset - case .overlap(let visibleOffset): - let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset - let inset = isHorizontal ? xInset : yInset - self.minimumLineSpacing = inset - fullSizeSideItemOverlap - } - } - - override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - return true - } - - override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - guard let superAttributes = super.layoutAttributesForElements(in: rect), - let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes] - else { return nil } - return attributes.map({ self.transformLayoutAttributes($0) }) - } - - fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { - guard let collectionView = self.collectionView else { return attributes } - let isHorizontal = (self.scrollDirection == .horizontal) - - let collectionCenter = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2 - let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y - let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset - - let maxDistance = (isHorizontal ? self.itemSize.width : self.itemSize.height) + self.minimumLineSpacing - let distance = min(abs(collectionCenter - normalizedCenter), maxDistance) - let ratio = (maxDistance - distance)/maxDistance - - let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha - let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale - let shift = (1 - ratio) * self.sideItemShift - attributes.alpha = alpha - attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1) - attributes.zIndex = Int(alpha * 10) - - if isHorizontal { - attributes.center.y = attributes.center.y + shift - } else { - attributes.center.x = attributes.center.x + shift - } - - return attributes - } - - override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { - guard let collectionView = collectionView , !collectionView.isPagingEnabled, - let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds) - else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) } - - let isHorizontal = (self.scrollDirection == .horizontal) - - let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2 - let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide - - var targetContentOffset: CGPoint - if isHorizontal { - let closest = layoutAttributes.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes() - targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y) - } - else { - let closest = layoutAttributes.sorted { abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes() - targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide)) - } - - return targetContentOffset - } -} -