Created
October 15, 2022 12:42
-
-
Save barabashd/4eb05455951eae008d0a57221d13612e 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
enum MediaLibrary {} | |
// MARK: - ViewModel | |
extension MediaLibrary { | |
final class ViewModel { | |
let progressCompletionThreshold: Double | |
var zoomPosition: ZoomPosition, | |
assetsCount: Int, | |
_zoomStatus: ZoomType? | |
var fraction: Double { | |
1 / Double(zoomPosition.rawValue) | |
} | |
var zoomStatus: ZoomType { | |
guard let zoomStatus = _zoomStatus else { | |
fatalError("zoomStatus can't be accessed as nil") | |
} | |
return zoomStatus | |
} | |
init( | |
zoomPosition: ZoomPosition = .middle, | |
assetsCount: Int, | |
progressCompletionThreshold: Double = 0.5 | |
) { | |
self.zoomPosition = zoomPosition | |
self.assetsCount = assetsCount | |
self.progressCompletionThreshold = progressCompletionThreshold | |
} | |
} | |
} | |
// MARK: - ZoomPosition | |
extension MediaLibrary.ViewModel { | |
enum ZoomPosition: Int { | |
case min = 19, | |
middleToMin = 9, | |
middle = 5, | |
middleToMax = 3, | |
max = 1 | |
private mutating func zoomOut() { | |
switch self { | |
case .min: | |
break | |
case .middleToMin: | |
self = .min | |
case .middle: | |
self = .middleToMin | |
case .middleToMax: | |
self = .middle | |
case .max: | |
self = .middleToMax | |
} | |
} | |
private mutating func zoomIn() { | |
switch self { | |
case .min: | |
self = .middleToMin | |
case .middleToMin: | |
self = .middle | |
case .middle: | |
self = .middleToMax | |
case .middleToMax: | |
self = .max | |
case .max: | |
break | |
} | |
} | |
mutating func finishZoom(for type: MediaLibrary.ViewModel.ZoomType) { | |
switch type { | |
case .zoomIn: | |
zoomIn() | |
case .zoomOut: | |
zoomOut() | |
} | |
} | |
} | |
} | |
// MARK: - ZoomType | |
extension MediaLibrary.ViewModel { | |
enum ZoomType { | |
case zoomIn, | |
zoomOut | |
func progress(from scale: CGFloat) -> CGFloat { | |
switch self { | |
case .zoomIn: | |
return scale - 1 | |
case .zoomOut: | |
return 2 * (1 - scale) | |
} | |
} | |
} | |
} | |
extension MediaLibrary { | |
final class ViewController: UIViewController { | |
enum Section { | |
case main | |
} | |
private let viewModel: ViewModel | |
private lazy var collectionView: UICollectionView = { | |
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) | |
collectionView.backgroundColor = .black | |
view.addSubview(collectionView) | |
collectionView.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.reusableIdentifier) | |
collectionView.translatesAutoresizingMaskIntoConstraints = false | |
NSLayoutConstraint.activate([ | |
collectionView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0), | |
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0), | |
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0), | |
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0) | |
]) | |
return collectionView | |
}() | |
private lazy var dataSource: UICollectionViewDiffableDataSource<Section, Int> = { | |
let dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { | |
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageCell.reusableIdentifier, for: indexPath) as? ImageCell else { | |
fatalError("can't dequeue ImageCell") | |
} | |
cell.configure() | |
return cell | |
} | |
return dataSource | |
}() | |
private lazy var pinchGesture: UIPinchGestureRecognizer = { | |
let pinchGesture = UIPinchGestureRecognizer() | |
pinchGesture.addTarget(self, action: #selector(handlePinchGesture(_:))) | |
return pinchGesture | |
}() | |
private var transitionLayout: UICollectionViewTransitionLayout { | |
guard let collectionViewLayout = collectionView.collectionViewLayout as? UICollectionViewTransitionLayout else { | |
fatalError("Unknown UICollectionViewTransitionLayout") | |
} | |
return collectionViewLayout | |
} | |
// MARK: - UICollectionViewLayout | |
private var layout: UICollectionViewLayout { | |
UICollectionViewCompositionalLayout( | |
section: .init( | |
group: .horizontal( | |
layoutSize: .init( | |
widthDimension: .fractionalWidth(1), | |
heightDimension: .fractionalWidth(viewModel.fraction) | |
), | |
subitems: [ | |
.init( | |
layoutSize: .init( | |
widthDimension: .fractionalWidth(viewModel.fraction), | |
heightDimension: .fractionalHeight(1) | |
) | |
) | |
] | |
) | |
) | |
) | |
} | |
init(viewModel: ViewModel) { | |
self.viewModel = viewModel | |
super.init(nibName: nil, bundle: nil) | |
applySnapshot() | |
enableGesture() | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
} | |
} | |
private extension MediaLibrary.ViewController { | |
func applySnapshot() { | |
// TODO: - update with more elagant way | |
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>() | |
snapshot.appendSections([.main]) | |
snapshot.appendItems(Array(0..<viewModel.assetsCount)) | |
dataSource.apply(snapshot, animatingDifferences: false) | |
} | |
@objc func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) { | |
switch gesture.state { | |
case .began: | |
viewModel._zoomStatus = gesture.zoomType | |
viewModel.zoomPosition.finishZoom(for: viewModel.zoomStatus) | |
collectionView.startInteractiveTransition(to: layout) { [unowned self] _, _ in | |
self.enableGesture() | |
} | |
case .changed: | |
print(gesture.progress(with: viewModel.zoomStatus)) | |
transitionLayout.transitionProgress = gesture.progress(with: viewModel.zoomStatus) | |
case .ended, .failed, .cancelled: | |
transitionLayout.transitionProgress > viewModel.progressCompletionThreshold ? finishPinchGesture() : cancelPinchGesture() | |
case .possible, .recognized: | |
break | |
@unknown default: | |
fatalError("Unknown new gesture status not handled yet!") | |
} | |
} | |
func finishPinchGesture() { | |
collectionView.finishInteractiveTransition() | |
disableGesture() | |
viewModel._zoomStatus = nil | |
} | |
func cancelPinchGesture() { | |
collectionView.cancelInteractiveTransition() | |
disableGesture() | |
} | |
func enableGesture() { | |
collectionView.addGestureRecognizer(pinchGesture) | |
} | |
func disableGesture() { | |
collectionView.removeGestureRecognizer(pinchGesture) | |
} | |
} | |
fileprivate extension UIPinchGestureRecognizer { | |
private static let minProgress: CGFloat = 0, | |
maxProgress: CGFloat = 1 | |
var zoomType: MediaLibrary.ViewModel.ZoomType { | |
scale > 1 ? .zoomIn : .zoomOut | |
} | |
// TODO: - make zoom continious | |
func progress(with zoomStatus: MediaLibrary.ViewModel.ZoomType) -> CGFloat { | |
max(Self.minProgress, min(zoomStatus.progress(from: scale), Self.maxProgress)) | |
} | |
} | |
extension UIColor { | |
static var random: UIColor { | |
let colors: [UIColor] = [.red, .yellow, .blue, .brown, .cyan, .darkGray, .green, .magenta, .orange] | |
return colors.randomElement()! | |
} | |
} | |
protocol HasReusableIdentifier { | |
static var reusableIdentifier: String { get } | |
} | |
extension HasReusableIdentifier { | |
static var reusableIdentifier: String { String(describing: self) } | |
} | |
final class ImageCell: UICollectionViewCell, HasReusableIdentifier { | |
func configure() { | |
backgroundColor = .random | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://youtube.com/shorts/YBn-nwbUH4Y?feature=share