Created
March 15, 2024 20:11
-
-
Save aheze/2244ba744ec486bc936c0dbc97da8edc to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Combine | |
import SwiftUI | |
class CoverFlowViewModel: ObservableObject { | |
@Published var items: [CoverFlowItem] = Theme.availableThemes.map { | |
CoverFlowItem( | |
id: $0.name, | |
title: $0.name, | |
color: $0.color, | |
needsPro: $0.needsPro | |
) | |
} | |
@Published var selectedItemID = "Blue" | |
var setSelectedItemID = PassthroughSubject<(String, IndexPath), Never>() | |
// "infinite scrolling" effect | |
let duplicationNumber = 100 | |
func getCenterIndex(selectedItemID: String) -> Int { | |
var centerIndex = (items.count * duplicationNumber) / 2 | |
// focus on the selected item first | |
for index in 0 ..< duplicationNumber * 2 { | |
if items.indices.contains(index) { | |
let candidate = items[index] | |
if candidate.id == selectedItemID { | |
centerIndex = centerIndex + index | |
continue | |
} | |
} | |
} | |
return centerIndex | |
} | |
} | |
class CoverFlowViewController: UIViewController { | |
var coverFlowViewModel: CoverFlowViewModel | |
lazy var cellRegistration: UICollectionView.CellRegistration<UICollectionViewCell, CoverFlowItem> = UICollectionView.CellRegistration { [weak self] cell, indexPath, itemIdentifier in | |
cell.contentConfiguration = UIHostingConfiguration { | |
CoverFlowItemView(item: itemIdentifier) { | |
self?.coverFlowViewModel.setSelectedItemID.send((itemIdentifier.id, indexPath)) | |
} | |
} | |
.margins(.all, 0) | |
} | |
var flowLayout = CoverFlowFlowLayout() | |
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) | |
var cancellables = Set<AnyCancellable>() | |
var loadedAtStart = false | |
init(coverFlowViewModel: CoverFlowViewModel) { | |
self.coverFlowViewModel = coverFlowViewModel | |
super.init(nibName: nil, bundle: nil) | |
} | |
override func loadView() { | |
view = UIView() | |
view.addSubview(collectionView) | |
_ = cellRegistration | |
collectionView.pinEdgesToSuperview() | |
collectionView.decelerationRate = .fast | |
collectionView.dataSource = self | |
collectionView.clipsToBounds = false | |
collectionView.showsHorizontalScrollIndicator = false | |
collectionView.backgroundColor = .clear | |
coverFlowViewModel.setSelectedItemID.sink { [weak self] selectedItemID, indexPath in | |
guard let self else { return } | |
self.coverFlowViewModel.selectedItemID = selectedItemID | |
let centerIndex = indexPath.item | |
if flowLayout.layoutAttributes.indices.contains(centerIndex) { | |
let attributes = flowLayout.layoutAttributes[centerIndex] | |
let centerOffset = (collectionView.bounds.width - flowLayout.itemWidth) / 2 | |
let x = attributes.originalX - centerOffset | |
collectionView.setContentOffset(CGPoint(x: x, y: 0), animated: true) | |
} | |
} | |
.store(in: &cancellables) | |
flowLayout.snappedToIndex = { [weak self] rawIndex in | |
guard let self else { return } | |
let index = rawIndex % self.coverFlowViewModel.items.count | |
if self.coverFlowViewModel.items.indices.contains(index) { | |
self.coverFlowViewModel.selectedItemID = self.coverFlowViewModel.items[index].id | |
} | |
} | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
super.viewDidAppear(animated) | |
guard !loadedAtStart else { | |
loadedAtStart = true | |
return | |
} | |
collectionView.reloadData() | |
let centerIndex = coverFlowViewModel.getCenterIndex(selectedItemID: coverFlowViewModel.selectedItemID) | |
if flowLayout.layoutAttributes.indices.contains(centerIndex) { | |
let attributes = flowLayout.layoutAttributes[centerIndex] | |
let centerOffset = (collectionView.bounds.width - flowLayout.itemWidth) / 2 | |
let x = attributes.originalX - centerOffset | |
collectionView.setContentOffset(CGPoint(x: x, y: 0), animated: false) | |
} | |
} | |
@available(*, unavailable) | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
} | |
extension CoverFlowViewController: UICollectionViewDataSource { | |
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { | |
return coverFlowViewModel.items.count * coverFlowViewModel.duplicationNumber | |
} | |
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { | |
let item = coverFlowViewModel.items[indexPath.item % coverFlowViewModel.items.count] | |
let cell = collectionView.dequeueConfiguredReusableCell( | |
using: cellRegistration, | |
for: indexPath, | |
item: item | |
) | |
return cell | |
} | |
} | |
class CoverFlowLayoutAttributes: UICollectionViewLayoutAttributes { | |
var originalX = CGFloat(0) | |
// unused for now, but TODO: | |
// use `override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes)` in UICollectionViewCell | |
// to adjust the alpha of each cell as it's scrolled | |
var distanceFromCenter = CGFloat(0) | |
override func copy(with zone: NSZone?) -> Any { | |
let copy = super.copy(with: zone) as! Self | |
copy.originalX = originalX | |
copy.distanceFromCenter = distanceFromCenter | |
return copy | |
} | |
override func isEqual(_ object: Any?) -> Bool { | |
guard let attributes = object as? Self else { return false } | |
guard | |
attributes.originalX == originalX, | |
attributes.distanceFromCenter == distanceFromCenter | |
else { return false } | |
return super.isEqual(object) | |
} | |
} | |
class CoverFlowFlowLayout: UICollectionViewFlowLayout { | |
// MARK: - Properties | |
let itemWidth = CGFloat(100) | |
var snappedToIndex: ((Int) -> Void)? | |
// MARK: - Flow Layout | |
var contentSize = CGSize.zero | |
override var collectionViewContentSize: CGSize { | |
contentSize | |
} | |
var layoutAttributes = [CoverFlowLayoutAttributes]() | |
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { | |
layoutAttributes[indexPath.item] | |
} | |
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { | |
layoutAttributes.filter { rect.intersects($0.frame) } | |
} | |
override func prepare() { | |
super.prepare() | |
guard let collectionView = collectionView else { return } | |
let section = 0 | |
let numberOfItems = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: section) ?? 0 | |
var x = CGFloat(0) | |
var layoutAttributes = [CoverFlowLayoutAttributes]() | |
for index in 0 ..< numberOfItems { | |
let indexPath = IndexPath(item: index, section: section) | |
let attributes = CoverFlowLayoutAttributes(forCellWith: indexPath) | |
let distanceFromCenter = (x + (itemWidth / 2) - collectionView.contentOffset.x) - (collectionView.bounds.width / 2) | |
let multiplier: CGFloat = distanceFromCenter > 0 ? 1 : -1 | |
var transform = CATransform3DIdentity | |
transform.m34 = 1 / -400 | |
let tilt = log(multiplier * distanceFromCenter + 1) / log(500) * CGFloat.pi * 0.2 | |
let tiltTransform = CATransform3DRotate(transform, multiplier * -tilt, 0, 1, 0) | |
let scale = 1 - log(multiplier * distanceFromCenter + 1) / log(500) * 0.06 | |
attributes.transform3D = CATransform3DScale(tiltTransform, scale, scale, 1) | |
var offset = (distanceFromCenter / 46) * 34 | |
offset -= multiplier * log(multiplier * distanceFromCenter / 50 + 1) * 50 | |
let frame = CGRect( | |
x: x - offset, | |
y: 0, | |
width: itemWidth, | |
height: collectionView.bounds.height | |
) | |
attributes.frame = frame | |
attributes.originalX = x | |
attributes.distanceFromCenter = distanceFromCenter | |
// make sure closer to center = on top (for button gestures) | |
if distanceFromCenter < 0 { | |
attributes.zIndex = index + numberOfItems / 2 | |
} else { | |
attributes.zIndex = numberOfItems - (index + numberOfItems / 2) | |
} | |
layoutAttributes.append(attributes) | |
x += itemWidth | |
} | |
self.layoutAttributes = layoutAttributes | |
contentSize = CGSize(width: x, height: collectionView.bounds.height) | |
} | |
// MARK: - Snapping | |
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { | |
let (index, offset) = getTargetOffset(for: proposedContentOffset, velocity: velocity.x) | |
snappedToIndex?(index) | |
return offset | |
} | |
/// called after rotation | |
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { | |
let (_, offset) = getTargetOffset(for: proposedContentOffset, velocity: 0) | |
return offset | |
} | |
func getTargetOffset(for point: CGPoint, velocity: CGFloat) -> (Int, CGPoint) { | |
guard let collectionView = collectionView else { return (0, point) } | |
let centerOffset = (collectionView.bounds.width - itemWidth) / 2 | |
let proposedOffset = point.x + collectionView.bounds.width / 2 | |
var pickedLayoutAttributes = [(Int, CoverFlowLayoutAttributes)]() | |
switch velocity { | |
case _ where velocity < 0: | |
pickedLayoutAttributes = layoutAttributes.enumerated().filter { _, layoutAttribute in | |
layoutAttribute.originalX + itemWidth / 2 <= proposedOffset | |
} | |
case _ where velocity > 0: | |
pickedLayoutAttributes = layoutAttributes.enumerated().filter { _, layoutAttribute in | |
layoutAttribute.originalX + itemWidth / 2 >= proposedOffset | |
} | |
default: | |
pickedLayoutAttributes = Array(layoutAttributes.enumerated()) | |
} | |
let closestAttributes = pickedLayoutAttributes.min { a, b in | |
let distanceA = abs(a.1.originalX + itemWidth / 2 - proposedOffset) | |
let distanceB = abs(b.1.originalX + itemWidth / 2 - proposedOffset) | |
return distanceA < distanceB | |
} | |
if let closestAttributes { | |
let point = CGPoint(x: closestAttributes.1.originalX - centerOffset, y: 0) | |
return (closestAttributes.0, point) | |
} else { | |
return (0, point) | |
} | |
} | |
} | |
// MARK: - SwiftUI | |
struct CoverFlowViewControllerRepresentable: UIViewControllerRepresentable { | |
@ObservedObject var coverFlowViewModel: CoverFlowViewModel | |
func makeUIViewController(context: Context) -> CoverFlowViewController { | |
CoverFlowViewController(coverFlowViewModel: coverFlowViewModel) | |
} | |
func updateUIViewController(_ uiViewController: CoverFlowViewController, context: Context) {} | |
} | |
struct CoverFlowItem { | |
var id = UUID().uuidString | |
var title = "Cover" | |
var color: Int = 0x00aeef | |
var needsPro = false | |
} | |
// MARK: - SwiftUI Cell | |
struct CoverFlowItemView: View { | |
var item: CoverFlowItem | |
var selected: (() -> Void)? | |
let spacing = CGFloat(5) | |
@State var pressing = false | |
var body: some View { | |
Button { | |
selected?() | |
} label: { | |
main | |
.scaleEffect(pressing ? 1.08 : 1) | |
.offset(y: pressing ? -20 : 0) | |
} | |
.buttonStyle(UnstyledButtonStyle()) | |
._onButtonGesture { pressing in | |
withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { | |
self.pressing = pressing | |
} | |
} perform: {} | |
} | |
var topButton: some View { | |
Circle() | |
.fill(.white) | |
.opacity(0.75) | |
} | |
var operatorButton: some View { | |
Circle() | |
.fill(Color(hex: item.color)) | |
.opacity(0.75) | |
} | |
var numberButton: some View { | |
Circle() | |
.fill(Color(hex: item.color)) | |
.brightness(0.75) | |
.opacity(0.2) | |
} | |
@ViewBuilder var main: some View { | |
VStack(spacing: spacing) { | |
Spacer() | |
HStack(spacing: spacing) { | |
topButton | |
topButton | |
operatorButton | |
} | |
HStack(spacing: spacing) { | |
numberButton | |
numberButton | |
operatorButton | |
} | |
HStack(spacing: spacing) { | |
numberButton | |
numberButton | |
operatorButton | |
} | |
} | |
.padding(8) | |
.background { | |
ZStack { | |
VisualEffectView(style: .systemChromeMaterialDark) | |
Color.black.opacity(0.3) | |
} | |
} | |
.clipShape(RoundedRectangle(cornerRadius: 12)) | |
.background { | |
RoundedRectangle(cornerRadius: 12) | |
.opacity(0.01) | |
.shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 12) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment