Skip to content

Instantly share code, notes, and snippets.

@aheze
Created March 15, 2024 20:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aheze/2244ba744ec486bc936c0dbc97da8edc to your computer and use it in GitHub Desktop.
Save aheze/2244ba744ec486bc936c0dbc97da8edc to your computer and use it in GitHub Desktop.
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