Last active
April 1, 2023 17:38
-
-
Save cjnevin/89f7364bedac6f9b6095be94880e1504 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 UIKit | |
enum Style { | |
static let backgroundColor = UIColor.black | |
static let spacing = CGFloat(8) | |
static let filterStackHeight = CGFloat(34) | |
static let filterHeaderHeight = (filterStackHeight * 2) + (spacing * 3) | |
} | |
class ViewController: UIViewController { | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
view.backgroundColor = Style.backgroundColor | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
super.viewDidAppear(animated) | |
showHome() | |
} | |
private func showHome() { | |
let vc = HomeViewController(viewModel: HomeViewModel(home: true) { [weak self] _ in | |
self?.showDetail() | |
}) | |
navigationController?.pushViewController(vc, animated: true) | |
} | |
private func showDetail() { | |
let vc = HomeViewController(viewModel: HomeViewModel(home: false) { _ in }) | |
navigationController?.pushViewController(vc, animated: true) | |
} | |
} | |
// Specific Implementation | |
class HomeViewModel { | |
enum NavigationAction { | |
case showDetail | |
} | |
let contentViewModel: ContentViewModel = .init(dataSources: []) | |
let heroDataSource: ContentDataSourceProtocol | |
let season1EpisodeDataSource: ContentDataSourceProtocol | |
let season2EpisodeDataSource: ContentDataSourceProtocol | |
let bonusEpisodeDataSource: ContentDataSourceProtocol | |
let deletedScenesEpisodeDataSource: ContentDataSourceProtocol | |
let movieDataSource: ContentDataSourceProtocol | |
let tvShow1DataSource: ContentDataSourceProtocol | |
let tvShow2DataSource: ContentDataSourceProtocol | |
let tvShow3DataSource: ContentDataSourceProtocol | |
var filterGroupDataSource: FilterGroupDataSource! | |
func updateContentViewModel(episodeDataSource: ContentDataSourceProtocol) { | |
if home { | |
contentViewModel.dataSources = [ | |
heroDataSource, | |
movieDataSource, | |
tvShow1DataSource, | |
tvShow2DataSource, | |
tvShow3DataSource | |
] | |
} else { | |
contentViewModel.dataSources = [ | |
heroDataSource, | |
filterGroupDataSource, | |
episodeDataSource | |
] | |
} | |
} | |
var home: Bool | |
init(home: Bool, navigationActionHandler: @escaping (NavigationAction) -> Void) { | |
self.home = home | |
let season1 = Identified<EpisodeGroup>( | |
id: "ep1", | |
content: EpisodeGroup( | |
items: [ | |
.init( | |
id: "ep1_it1", | |
content: .init( | |
color: .purple, | |
title: "Episode 1", | |
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." | |
) | |
), | |
.init( | |
id: "ep1_it2", | |
content: .init( | |
color: .red, | |
title: "Episode 2", | |
description: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." | |
) | |
), | |
.init( | |
id: "ep1_it3", | |
content: .init( | |
color: .blue, | |
title: "Episode 3", | |
description: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." | |
) | |
), | |
.init( | |
id: "ep1_it4", | |
content: .init( | |
color: .systemPink, | |
title: "Episode 4", | |
description: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." | |
) | |
), | |
.init( | |
id: "ep1_it5", | |
content: .init( | |
color: .systemMint, | |
title: "Episode 5", | |
description: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo." | |
) | |
), | |
] | |
) | |
) | |
let season2 = Identified<EpisodeGroup>( | |
id: "ep2", | |
content: EpisodeGroup( | |
items: [ | |
.init( | |
id: "ep2_it1", | |
content: .init( | |
color: .orange, | |
title: "Episode 1", | |
description: "Lorem ipsum dolor." | |
) | |
), | |
.init( | |
id: "ep2_it2", | |
content: .init( | |
color: .yellow, | |
title: "Episode 2", | |
description: "Ut enim ad minim." | |
) | |
), | |
.init( | |
id: "ep2_it3", | |
content: .init( | |
color: .cyan, | |
title: "Episode 3", | |
description: "Duis aute irure." | |
) | |
), | |
] | |
) | |
) | |
let bonus = Identified<EpisodeGroup>( | |
id: "bn1", | |
content: EpisodeGroup( | |
items: [ | |
.init( | |
id: "bn1_it1", | |
content: .init( | |
color: .gray, | |
title: "Bonus 1", | |
description: "Lorem ipsum dolor." | |
) | |
), | |
.init( | |
id: "bn2_it2", | |
content: .init( | |
color: .gray, | |
title: "Bonus 2", | |
description: "Ut enim ad minim." | |
) | |
), | |
.init( | |
id: "bn3_it3", | |
content: .init( | |
color: .gray, | |
title: "Bonus 3", | |
description: "Duis aute irure." | |
) | |
), | |
] | |
) | |
) | |
let deletedScenes = Identified<EpisodeGroup>( | |
id: "ds1", | |
content: EpisodeGroup( | |
items: [ | |
.init( | |
id: "ds1_it1", | |
content: .init( | |
color: .magenta, | |
title: "Deleted Scene 1", | |
description: "Lorem ipsum dolor." | |
) | |
), | |
.init( | |
id: "ds2_it2", | |
content: .init( | |
color: .magenta, | |
title: "Deleted Scene 2", | |
description: "Ut enim ad minim." | |
) | |
), | |
.init( | |
id: "ds3_it3", | |
content: .init( | |
color: .magenta, | |
title: "Deleted Scene 3", | |
description: "Duis aute irure." | |
) | |
), | |
] | |
) | |
) | |
heroDataSource = HeroGroupDataSource { target in | |
print("HeroGroup navigation to \(target)") | |
} | |
season1EpisodeDataSource = EpisodeGroupDataSource(episodeGroup: season1) { target in | |
print("Season1 navigation to \(target)") | |
} | |
season2EpisodeDataSource = EpisodeGroupDataSource(episodeGroup: season2) { target in | |
print("Season2 navigation to \(target)") | |
} | |
bonusEpisodeDataSource = EpisodeGroupDataSource(episodeGroup: bonus) { target in | |
print("Bonus navigation to \(target)") | |
} | |
deletedScenesEpisodeDataSource = EpisodeGroupDataSource(episodeGroup: deletedScenes) { target in | |
print("Deleted scenes navigation to \(target)") | |
} | |
movieDataSource = MovieGroupDataSource { target in | |
print("MovieGroup navigation to \(target)") | |
} | |
tvShow1DataSource = TvShowGroupDataSource { target in | |
print("TvShowGroup navigation to \(target)") | |
navigationActionHandler(.showDetail) | |
} | |
tvShow2DataSource = TvShowGroupDataSource { target in | |
print("TvShowGroup navigation to \(target)") | |
navigationActionHandler(.showDetail) | |
} | |
tvShow3DataSource = TvShowGroupDataSource { target in | |
print("TvShowGroup navigation to \(target)") | |
navigationActionHandler(.showDetail) | |
} | |
filterGroupDataSource = FilterGroupDataSource { target in | |
print("FilterGroup navigation to \(target)") | |
} | |
filterGroupDataSource.changeHandlers.append { filterGroup in | |
let subFilter = filterGroup.filters | |
.first(where: \.content.isSelected)? | |
.content | |
.subFilters | |
.first(where: \.content.isSelected) | |
guard let subFilter else { return } | |
switch subFilter.id.id { | |
case "hr1_f1_sf1": | |
self.updateContentViewModel(episodeDataSource: self.season1EpisodeDataSource) | |
case "hr1_f2_sf1": | |
self.updateContentViewModel(episodeDataSource: self.bonusEpisodeDataSource) | |
case "hr1_f2_sf2": | |
self.updateContentViewModel(episodeDataSource: self.deletedScenesEpisodeDataSource) | |
default: | |
self.updateContentViewModel(episodeDataSource: self.season2EpisodeDataSource) | |
} | |
} | |
updateContentViewModel(episodeDataSource: season1EpisodeDataSource) | |
} | |
} | |
class HomeViewController: UIViewController, UICollectionViewDelegate { | |
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) | |
private lazy var dataSource = ContentCollectionViewDataSource() | |
private let viewModel: HomeViewModel | |
private lazy var filterView = FilterHeader() | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
navigationController?.navigationBar.barStyle = .black | |
view.backgroundColor = Style.backgroundColor | |
view.addSubview(collectionView) | |
collectionView.backgroundColor = Style.backgroundColor | |
collectionView.translatesAutoresizingMaskIntoConstraints = false | |
collectionView.dataSource = dataSource | |
collectionView.delegate = self | |
NSLayoutConstraint.activate([ | |
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), | |
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), | |
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), | |
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), | |
]) | |
if !viewModel.home { | |
filterView.translatesAutoresizingMaskIntoConstraints = false | |
filterView.alpha = 0 | |
view.addSubview(filterView) | |
NSLayoutConstraint.activate([ | |
filterView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), | |
filterView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), | |
filterView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), | |
filterView.heightAnchor.constraint(equalToConstant: Style.filterHeaderHeight) | |
]) | |
filterView.backgroundColor = Style.backgroundColor | |
viewModel.filterGroupDataSource.configureStatic(filterView) | |
} | |
collectionView.collectionViewLayout = viewModel.contentViewModel.layout | |
viewModel.contentViewModel.register(in: collectionView) | |
// Do any additional setup after loading the view. | |
viewModel.contentViewModel.callback = { [weak self] sections in | |
self?.dataSource.updateSections(sections) | |
self?.collectionView.reloadData() | |
} | |
viewModel.contentViewModel.updateViewState() | |
} | |
init(viewModel: HomeViewModel) { | |
self.viewModel = viewModel | |
super.init(nibName: nil, bundle: nil) | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { | |
true | |
} | |
override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { | |
true | |
} | |
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { | |
true | |
} | |
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { | |
dataSource.tappedItem(at: indexPath) | |
} | |
override var preferredFocusEnvironments: [UIFocusEnvironment] { | |
[collectionView] | |
} | |
func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { | |
if let previousIndexPath = context.previouslyFocusedIndexPath, | |
let cell = collectionView.cellForItem(at: previousIndexPath) { | |
cell.contentView.layer.borderWidth = 0.0 | |
cell.contentView.layer.shadowRadius = 0.0 | |
cell.contentView.layer.shadowOpacity = 0 | |
} | |
if let indexPath = context.nextFocusedIndexPath, | |
let cell = collectionView.cellForItem(at: indexPath) { | |
cell.contentView.layer.borderWidth = 8.0 | |
cell.contentView.layer.borderColor = UIColor.white.cgColor | |
cell.contentView.layer.shadowColor = UIColor.white.cgColor | |
cell.contentView.layer.shadowRadius = 10.0 | |
cell.contentView.layer.shadowOpacity = 0.9 | |
cell.contentView.layer.shadowOffset = CGSize(width: 0, height: 0) | |
collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: true) | |
} | |
} | |
func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
filterView.alpha = scrollView.contentOffset.y >= 300 ? 1 : 0 | |
} | |
} | |
// MARK: - Erased/Reusable | |
struct Section { | |
let reusableView: (String, UICollectionView, IndexPath) -> UICollectionReusableView | |
let cells: [Cell] | |
} | |
struct Cell { | |
let configure: (UICollectionView, IndexPath) -> UICollectionViewCell | |
let tapped: () -> Void | |
} | |
protocol ContentDataSourceProtocol: AnyObject { | |
var layoutSection: NSCollectionLayoutSection { get } | |
var onUpdate: () -> Void { get set } | |
var section: Section { get } | |
func register(in collectionView: UICollectionView) | |
} | |
class ContentViewModel { | |
var dataSources: [ContentDataSourceProtocol] { | |
didSet { | |
updateViewState() | |
} | |
} | |
var layout: UICollectionViewLayout { | |
let layoutSections = dataSources.map(\.layoutSection) | |
let configuration = UICollectionViewCompositionalLayoutConfiguration() | |
let layout = UICollectionViewCompositionalLayout( | |
sectionProvider: { index, _ in layoutSections[index] }, | |
configuration: configuration | |
) | |
return layout | |
} | |
var callback: ([Section]) -> Void = { _ in } | |
init(dataSources: [ContentDataSourceProtocol]) { | |
self.dataSources = dataSources | |
dataSources.forEach { | |
$0.onUpdate = { [weak self] in | |
self?.updateViewState() | |
} | |
} | |
} | |
func register(in collectionView: UICollectionView) { | |
dataSources.forEach { | |
$0.register(in: collectionView) | |
} | |
} | |
func updateViewState() { | |
let sections = dataSources.map(\.section) | |
callback(sections) | |
} | |
} | |
class ContentCollectionViewDataSource: NSObject, UICollectionViewDataSource { | |
private(set) var sections: [Section] = [] | |
func tappedItem(at indexPath: IndexPath) { | |
sections[indexPath.section].cells[indexPath.row].tapped() | |
} | |
func updateSections(_ newSections: [Section]) { | |
sections = newSections | |
} | |
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { | |
sections[section].cells.count | |
} | |
func numberOfSections(in collectionView: UICollectionView) -> Int { | |
sections.count | |
} | |
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { | |
sections[indexPath.section].reusableView(kind, collectionView, indexPath) | |
} | |
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { | |
sections[indexPath.section].cells[indexPath.row].configure(collectionView, indexPath) | |
} | |
} | |
func makeLayoutSection( | |
insets: NSDirectionalEdgeInsets, | |
size: CGSize, | |
horizontal: Bool = true, | |
spacing: CGFloat = Style.spacing, | |
scrollingBehaviour: UICollectionLayoutSectionOrthogonalScrollingBehavior = .continuous | |
) -> NSCollectionLayoutSection { | |
let itemSize = NSCollectionLayoutSize( | |
widthDimension: .fractionalWidth(1.0), | |
heightDimension: .estimated(size.height) | |
) | |
let item = NSCollectionLayoutItem(layoutSize: itemSize) | |
let groupSize = NSCollectionLayoutSize( | |
widthDimension: size.width == .greatestFiniteMagnitude ? .fractionalWidth(1.0) : .absolute(size.width), | |
heightDimension: .estimated(size.height) | |
) | |
let group = horizontal ? NSCollectionLayoutGroup.horizontal( | |
layoutSize: groupSize, | |
subitems: [item] | |
) : .vertical( | |
layoutSize: groupSize, | |
subitems: [item] | |
) | |
let section = NSCollectionLayoutSection(group: group) | |
section.contentInsets = insets | |
section.interGroupSpacing = spacing | |
section.orthogonalScrollingBehavior = scrollingBehaviour | |
return section | |
} | |
// Domain | |
struct Identifier<T>: Equatable, Hashable { | |
let id: String | |
} | |
struct Identified<T: Equatable>: Equatable { | |
let id: Identifier<T> | |
var content: T | |
} | |
extension Identified { | |
init(id: String, content: T) { | |
self.id = .init(id: id) | |
self.content = content | |
} | |
} | |
// MARK: - HeroGroup | |
struct HeroGroup: Equatable { | |
let items: [Identified<Item>] | |
struct Item: Equatable { | |
struct View: Equatable { | |
let color: UIColor | |
let title: String | |
} | |
let views: [View] | |
} | |
} | |
class HeroCell: UICollectionViewCell { | |
private var models: [HeroGroup.Item.View] = [] | |
private lazy var pageControl = UIPageControl() | |
private lazy var label = UILabel() | |
private var index = 0 { | |
didSet { | |
UIView.transition(with: contentView, duration: 0.3) { | |
self.contentView.backgroundColor = self.models[self.index].color | |
self.label.text = self.models[self.index].title | |
self.pageControl.currentPage = self.index | |
} | |
} | |
} | |
@objc private func swipedLeft() { | |
index = (index + 1) % pageControl.numberOfPages | |
} | |
@objc private func swipedRight() { | |
if index > 0 { | |
index = index - 1 | |
} else { | |
index = pageControl.numberOfPages - 1 | |
} | |
} | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
label.textColor = .white | |
label.font = .boldSystemFont(ofSize: 24) | |
label.shadowColor = .black | |
label.shadowOffset = .init(width: 2, height: 2) | |
label.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), | |
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), | |
label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 10), | |
label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -10) | |
]) | |
pageControl.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(pageControl) | |
NSLayoutConstraint.activate([ | |
pageControl.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), | |
pageControl.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), | |
]) | |
} | |
func configure(with item: HeroGroup.Item) { | |
models = item.views | |
pageControl.numberOfPages = item.views.count | |
pageControl.isUserInteractionEnabled = false | |
label.text = item.views.first?.title | |
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft)) | |
leftSwipeGesture.direction = .left | |
contentView.addGestureRecognizer(leftSwipeGesture) | |
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight)) | |
rightSwipeGesture.direction = .right | |
contentView.addGestureRecognizer(rightSwipeGesture) | |
contentView.isUserInteractionEnabled = true | |
index = 0 | |
} | |
} | |
extension Section { | |
static func heroGroup( | |
_ heroGroup: Identified<HeroGroup>, | |
onItemTapped: @escaping (Identifier<HeroGroup.Item>) -> Void | |
) -> Section { | |
.init(reusableView: { kind, collectionView, indexPath in | |
.init(frame: .zero) | |
}, cells: heroGroup.content.items.map { item in | |
.hero( | |
item, | |
onTapped: onItemTapped | |
) | |
}) | |
} | |
} | |
extension Cell { | |
static func hero( | |
_ hero: Identified<HeroGroup.Item>, | |
onTapped: @escaping (Identifier<HeroGroup.Item>) -> Void | |
) -> Cell { | |
.init { collectionView, indexPath in | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "HeroCell", for: indexPath) as? HeroCell else { | |
return .init() | |
} | |
cell.configure(with: hero.content) | |
return cell | |
} tapped: { | |
onTapped(hero.id) | |
} | |
} | |
} | |
class HeroGroupDataSource: ContentDataSourceProtocol { | |
enum NavigationTarget { | |
case showHero(Identifier<HeroGroup.Item>) | |
} | |
var onUpdate: () -> Void = {} | |
var layoutSection: NSCollectionLayoutSection { | |
let layoutSection = makeLayoutSection( | |
insets: .zero, | |
size: .init(width: CGFloat.greatestFiniteMagnitude, height: 300), | |
spacing: 0, | |
scrollingBehaviour: .paging | |
) | |
layoutSection.visibleItemsInvalidationHandler = { items, offset, _ in | |
items.forEach { item in | |
item.transform = CGAffineTransform.identity.translatedBy(x: 0, y: max(offset.y, 0)) | |
item.zIndex = 0 | |
item.alpha = 1.0 - (offset.y / item.frame.height) | |
} | |
} | |
return layoutSection | |
} | |
private var onNavigate: (NavigationTarget) -> Void | |
private var heroGroup: Identified<HeroGroup> | |
init(onNavigate: @escaping (NavigationTarget) -> Void) { | |
self.onNavigate = onNavigate | |
self.heroGroup = Identified<HeroGroup>( | |
id: "hr1", | |
content: HeroGroup( | |
items: [ | |
Identified<HeroGroup.Item>.init( | |
id: "hr1_it1", | |
content: .init( | |
views: [ | |
.init( | |
color: .purple, | |
title: "Purple" | |
), | |
.init( | |
color: .brown, | |
title: "Brown" | |
), | |
.init( | |
color: .orange, | |
title: "Orange" | |
), | |
.init( | |
color: .darkGray, | |
title: "Dark Gray" | |
) | |
] | |
) | |
), | |
] | |
) | |
) | |
} | |
func register(in collectionView: UICollectionView) { | |
collectionView.register(HeroCell.self, forCellWithReuseIdentifier: "HeroCell") | |
} | |
var section: Section { | |
Section.heroGroup(heroGroup) { [weak self] id in | |
self?.onNavigate(.showHero(id)) | |
} | |
} | |
} | |
// MARK: - FilterGroup | |
struct FilterGroup: Equatable { | |
struct Filter: Equatable { | |
struct SubFilter: Equatable { | |
let title: String | |
var isSelected: Bool | |
} | |
let title: String | |
let subFilters: [Identified<SubFilter>] | |
var isSelected: Bool | |
} | |
let filters: [Identified<Filter>] | |
} | |
class FilterHeader: UICollectionReusableView { | |
class ScrollableStackView: UIScrollView { | |
private lazy var contentView = UIView() | |
private lazy var stackView = UIStackView() | |
var selectedFilter: (Identifier<FilterGroup.Filter>) -> Void = { _ in } | |
var selectedSubFilter: (Identifier<FilterGroup.Filter.SubFilter>) -> Void = { _ in } | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
contentView.translatesAutoresizingMaskIntoConstraints = false | |
addSubview(contentView) | |
NSLayoutConstraint.activate([ | |
contentView.leadingAnchor.constraint(equalTo: leadingAnchor), | |
contentView.trailingAnchor.constraint(equalTo: trailingAnchor), | |
contentView.widthAnchor.constraint(equalTo: widthAnchor), | |
contentView.topAnchor.constraint(equalTo: topAnchor), | |
contentView.bottomAnchor.constraint(equalTo: bottomAnchor), | |
contentView.heightAnchor.constraint(equalToConstant: Style.filterStackHeight) | |
]) | |
stackView.translatesAutoresizingMaskIntoConstraints = false | |
stackView.spacing = Style.spacing | |
stackView.distribution = .fillProportionally | |
contentView.addSubview(stackView) | |
NSLayoutConstraint.activate([ | |
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), | |
stackView.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor), | |
stackView.topAnchor.constraint(equalTo: contentView.topAnchor), | |
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) | |
]) | |
} | |
private func subFilterSelected(_ subFilter: Identifier<FilterGroup.Filter.SubFilter>, at index: Int) { | |
selectedSubFilter(subFilter) | |
stackView.arrangedSubviews.compactMap { $0 as? UIButton }.enumerated().forEach { x, button in | |
button.isSelected = x == index | |
} | |
} | |
func configure(with group: FilterGroup) { | |
stackView.arrangedSubviews.forEach { | |
$0.removeFromSuperview() | |
stackView.removeArrangedSubview($0) | |
} | |
stackView.addArrangedSubview(UIView()) | |
group.filters.forEach { filter in | |
let button = UIButton(frame: .zero, primaryAction: .init(handler: { [weak self] action in | |
self?.selectedFilter(filter.id) | |
})) | |
var buttonConfiguration = UIButton.Configuration.gray() | |
buttonConfiguration.cornerStyle = .small | |
buttonConfiguration.title = filter.content.title | |
buttonConfiguration.baseForegroundColor = .white | |
button.configuration = buttonConfiguration | |
button.configurationUpdateHandler = { button in | |
switch button.state { | |
case [.selected, .highlighted], .selected, .highlighted: | |
button.configuration?.baseForegroundColor = .white | |
default: | |
button.configuration?.baseForegroundColor = .lightGray | |
} | |
} | |
button.isSelected = filter.content.isSelected | |
stackView.addArrangedSubview(button) | |
} | |
stackView.addArrangedSubview(UIView()) | |
} | |
func configure(with filter: FilterGroup.Filter) { | |
stackView.arrangedSubviews.forEach { | |
$0.removeFromSuperview() | |
stackView.removeArrangedSubview($0) | |
} | |
stackView.addArrangedSubview(UIView()) | |
filter.subFilters.forEach { subFilter in | |
let button = UIButton(frame: .zero, primaryAction: .init(handler: { [weak self] action in | |
self?.selectedSubFilter(subFilter.id) | |
})) | |
var buttonConfiguration = UIButton.Configuration.gray() | |
buttonConfiguration.cornerStyle = .small | |
buttonConfiguration.title = subFilter.content.title | |
buttonConfiguration.baseForegroundColor = .white | |
button.configuration = buttonConfiguration | |
button.configurationUpdateHandler = { button in | |
switch button.state { | |
case [.selected, .highlighted], .selected, .highlighted: | |
button.configuration?.baseForegroundColor = .white | |
default: | |
button.configuration?.baseForegroundColor = .lightGray | |
} | |
} | |
button.isSelected = subFilter.content.isSelected | |
stackView.addArrangedSubview(button) | |
} | |
stackView.addArrangedSubview(UIView()) | |
} | |
} | |
private lazy var stackView = UIStackView(arrangedSubviews: [filters, subFilters]) | |
private lazy var filters = ScrollableStackView() | |
private lazy var subFilters = ScrollableStackView() | |
var onFilterSelected: (Identifier<FilterGroup.Filter>) -> Void = { _ in } | |
var onSubFilterSelected: (Identifier<FilterGroup.Filter.SubFilter>) -> Void = { _ in } | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
filters.selectedFilter = { [weak self] filter in | |
self?.onFilterSelected(filter) | |
} | |
filters.translatesAutoresizingMaskIntoConstraints = false | |
addSubview(filters) | |
subFilters.selectedSubFilter = { [weak self] subFilter in | |
self?.onSubFilterSelected(subFilter) | |
} | |
stackView.translatesAutoresizingMaskIntoConstraints = false | |
stackView.axis = .vertical | |
stackView.spacing = Style.spacing | |
stackView.distribution = .fillEqually | |
addSubview(stackView) | |
NSLayoutConstraint.activate([ | |
stackView.leadingAnchor.constraint(equalTo: leadingAnchor), | |
stackView.trailingAnchor.constraint(equalTo: trailingAnchor), | |
stackView.topAnchor.constraint(equalTo: topAnchor, constant: Style.spacing), | |
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Style.spacing), | |
]) | |
} | |
func configure(with group: FilterGroup) { | |
filters.configure(with: group) | |
subFilters.configure(with: group.filters.filter(\.content.isSelected).first!.content) | |
} | |
} | |
extension Section { | |
static func filterGroup( | |
_ filterGroup: Identified<FilterGroup>, | |
onFilterSelected: @escaping (Identifier<FilterGroup.Filter>) -> FilterGroup, | |
onSubFilterSelected: @escaping (Identifier<FilterGroup.Filter.SubFilter>) -> FilterGroup | |
) -> Section { | |
.init(reusableView: { kind, collectionView, indexPath in | |
guard let filterHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "FilterHeader", for: indexPath) as? FilterHeader else { | |
return .init(frame: .zero) | |
} | |
filterHeader.configure(with: filterGroup.content) | |
filterHeader.onFilterSelected = { id in | |
filterHeader.configure(with: onFilterSelected(id)) | |
} | |
filterHeader.onSubFilterSelected = { id in | |
filterHeader.configure(with: onSubFilterSelected(id)) | |
} | |
return filterHeader | |
}, cells: []) | |
} | |
} | |
class FilterGroupDataSource: ContentDataSourceProtocol { | |
enum NavigationTarget { | |
case showSubFilter(Identifier<FilterGroup.Filter.SubFilter>) | |
} | |
var changeHandlers: [(FilterGroup) -> Void] = [] | |
var onUpdate: () -> Void = {} | |
var layoutSection: NSCollectionLayoutSection { | |
let layoutSection = makeLayoutSection( | |
insets: .init(top: Style.spacing, leading: 0, bottom: 0, trailing: 0), | |
size: .init(width: CGFloat.greatestFiniteMagnitude, height: 1) | |
) | |
layoutSection.boundarySupplementaryItems = [ | |
.init( | |
layoutSize: .init( | |
widthDimension: .fractionalWidth(1.0), | |
heightDimension: .estimated(Style.filterHeaderHeight) | |
), | |
elementKind: "Header", | |
alignment: .top | |
) | |
] | |
return layoutSection | |
} | |
private var onNavigate: (NavigationTarget) -> Void | |
private(set) var filterGroup: Identified<FilterGroup> | |
init(onNavigate: @escaping (NavigationTarget) -> Void) { | |
self.onNavigate = onNavigate | |
self.filterGroup = Identified<FilterGroup>( | |
id: "fg1", | |
content: FilterGroup( | |
filters: [ | |
.init( | |
id: "hr1_f1", | |
content: .init( | |
title: "Seasons", | |
subFilters: [ | |
.init( | |
id: "hr1_f1_sf1", | |
content: .init( | |
title: "Season 1", | |
isSelected: true | |
) | |
), | |
.init( | |
id: "hr1_f1_sf2", | |
content: .init( | |
title: "Season 2", | |
isSelected: false | |
) | |
) | |
], | |
isSelected: true | |
) | |
), | |
.init( | |
id: "hr1_f2", | |
content: .init( | |
title: "Extras", | |
subFilters: [ | |
.init( | |
id: "hr1_f2_sf1", | |
content: .init( | |
title: "Bonus", | |
isSelected: true | |
) | |
), | |
.init( | |
id: "hr1_f2_sf2", | |
content: .init( | |
title: "Deleted Scenes", | |
isSelected: false | |
) | |
) | |
], | |
isSelected: false | |
) | |
) | |
] | |
) | |
) | |
} | |
func register(in collectionView: UICollectionView) { | |
collectionView.register(FilterHeader.self, forSupplementaryViewOfKind: "Header", withReuseIdentifier: "FilterHeader") | |
} | |
func configureStatic(_ filterHeader: FilterHeader) { | |
filterHeader.configure(with: filterGroup.content) | |
changeHandlers.append { [weak filterHeader] filterGroup in | |
filterHeader?.configure(with: filterGroup) | |
} | |
filterHeader.onFilterSelected = { [weak self] id in | |
self?.toggleSelection(id) | |
} | |
filterHeader.onSubFilterSelected = { [weak self] id in | |
self?.toggleSelection(id) | |
self?.onNavigate(.showSubFilter(id)) | |
} | |
} | |
var section: Section { | |
Section.filterGroup(filterGroup) { [weak self] id in | |
guard let self else { return .init(filters: []) } | |
self.toggleSelection(id) | |
return self.filterGroup.content | |
} onSubFilterSelected: { [weak self] id in | |
guard let self else { return .init(filters: []) } | |
self.toggleSelection(id) | |
self.onNavigate(.showSubFilter(id)) | |
return self.filterGroup.content | |
} | |
} | |
private func toggleSelection(_ id: Identifier<FilterGroup.Filter.SubFilter>) { | |
filterGroup = .init( | |
id: filterGroup.id, | |
content: FilterGroup( | |
filters: filterGroup.content.filters.map { filter in | |
.init( | |
id: filter.id, | |
content: .init( | |
title: filter.content.title, | |
subFilters: filter.content.subFilters.map { subFilter in | |
.init( | |
id: subFilter.id, | |
content: .init( | |
title: subFilter.content.title, | |
isSelected: id == subFilter.id | |
) | |
) | |
}, | |
isSelected: filter.content.isSelected | |
) | |
) | |
} | |
) | |
) | |
onChange() | |
} | |
private func onChange() { | |
changeHandlers.forEach { | |
$0(filterGroup.content) | |
} | |
onUpdate() | |
} | |
private func toggleSelection(_ id: Identifier<FilterGroup.Filter>) { | |
filterGroup = .init( | |
id: filterGroup.id, | |
content: FilterGroup( | |
filters: filterGroup.content.filters.map { filter in | |
.init( | |
id: filter.id, | |
content: .init( | |
title: filter.content.title, | |
subFilters: filter.content.subFilters.enumerated().map { index, subFilter in | |
.init( | |
id: subFilter.id, | |
content: .init( | |
title: subFilter.content.title, | |
isSelected: index == 0 | |
) | |
) | |
}, | |
isSelected: id == filter.id | |
) | |
) | |
} | |
) | |
) | |
onChange() | |
} | |
} | |
class EpisodeGroupDataSource: ContentDataSourceProtocol { | |
enum NavigationTarget { | |
case showEpisode(Identifier<EpisodeGroup.Item>) | |
} | |
var onUpdate: () -> Void = {} | |
var layoutSection: NSCollectionLayoutSection { | |
let layoutSection = makeLayoutSection( | |
insets: .init(top: Style.spacing, leading: Style.spacing, bottom: 0, trailing: Style.spacing), | |
size: .init(width: CGFloat.greatestFiniteMagnitude, height: 44), | |
horizontal: false, | |
scrollingBehaviour: .none | |
) | |
return layoutSection | |
} | |
private var onNavigate: (NavigationTarget) -> Void | |
private var episodeGroup: Identified<EpisodeGroup> | |
init(episodeGroup: Identified<EpisodeGroup>, onNavigate: @escaping (NavigationTarget) -> Void) { | |
self.onNavigate = onNavigate | |
self.episodeGroup = episodeGroup | |
} | |
func register(in collectionView: UICollectionView) { | |
collectionView.register(EpisodeCell.self, forCellWithReuseIdentifier: "EpisodeCell") | |
} | |
var section: Section { | |
Section.episodeGroup(episodeGroup) { [weak self] id in | |
self?.onNavigate(.showEpisode(id)) | |
} | |
} | |
} | |
// MARK: - EpisodeGroup | |
struct EpisodeGroup: Equatable { | |
let items: [Identified<Item>] | |
struct Item: Equatable { | |
let color: UIColor | |
let title: String | |
let description: String | |
} | |
} | |
class EpisodeCell: UICollectionViewCell { | |
private lazy var snapshot = UIView() | |
private lazy var stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) | |
private lazy var titleLabel = UILabel() | |
private lazy var descriptionLabel = UILabel() | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
snapshot.layer.cornerRadius = 3 | |
snapshot.layer.masksToBounds = true | |
snapshot.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(snapshot) | |
NSLayoutConstraint.activate([ | |
snapshot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), | |
snapshot.topAnchor.constraint(equalTo: contentView.topAnchor), | |
snapshot.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), | |
snapshot.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.3), | |
snapshot.heightAnchor.constraint(equalTo: snapshot.widthAnchor, multiplier: 0.75) | |
]) | |
stackView.axis = .vertical | |
stackView.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(stackView) | |
NSLayoutConstraint.activate([ | |
stackView.leadingAnchor.constraint(equalTo: snapshot.trailingAnchor, constant: Style.spacing), | |
stackView.topAnchor.constraint(equalTo: contentView.topAnchor), | |
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), | |
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Style.spacing), | |
]) | |
titleLabel.textColor = .white | |
titleLabel.font = .boldSystemFont(ofSize: 16) | |
descriptionLabel.textColor = .lightGray | |
descriptionLabel.lineBreakMode = .byWordWrapping | |
descriptionLabel.numberOfLines = 0 | |
} | |
func configure(with item: EpisodeGroup.Item) { | |
titleLabel.text = item.title | |
descriptionLabel.text = item.description | |
snapshot.backgroundColor = item.color | |
} | |
} | |
extension Section { | |
static func episodeGroup( | |
_ episodeGroup: Identified<EpisodeGroup>, | |
onItemTapped: @escaping (Identifier<EpisodeGroup.Item>) -> Void | |
) -> Section { | |
.init(reusableView: { kind, collectionView, indexPath in | |
.init(frame: .zero) | |
}, cells: episodeGroup.content.items.map { item in | |
.episode( | |
item, | |
onTapped: onItemTapped | |
) | |
}) | |
} | |
} | |
extension Cell { | |
static func episode( | |
_ episode: Identified<EpisodeGroup.Item>, | |
onTapped: @escaping (Identifier<EpisodeGroup.Item>) -> Void | |
) -> Cell { | |
.init { collectionView, indexPath in | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "EpisodeCell", for: indexPath) as? EpisodeCell else { | |
return .init() | |
} | |
cell.configure(with: episode.content) | |
return cell | |
} tapped: { | |
onTapped(episode.id) | |
} | |
} | |
} | |
// MARK: - MovieGroup | |
struct MovieGroup: Equatable { | |
let items: [Identified<Item>] | |
struct Item: Equatable { | |
let title: String | |
var isFavourite: Bool | |
} | |
} | |
class MovieCell: UICollectionViewCell { | |
private lazy var label = UILabel() | |
private lazy var favouriteButton = UIButton() | |
var onFavouriteTapped: () -> Void = {} | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
contentView.backgroundColor = .red | |
contentView.layer.cornerRadius = 10 | |
contentView.layer.masksToBounds = true | |
favouriteButton.addTarget(self, action: #selector(tappedFavourite), for: .touchUpInside) | |
favouriteButton.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(favouriteButton) | |
NSLayoutConstraint.activate([ | |
favouriteButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2), | |
favouriteButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -2), | |
favouriteButton.heightAnchor.constraint(equalToConstant: 44), | |
favouriteButton.widthAnchor.constraint(equalToConstant: 44) | |
]) | |
label.textColor = .white | |
label.font = .boldSystemFont(ofSize: 24) | |
label.shadowColor = .black | |
label.shadowOffset = .init(width: 2, height: 2) | |
label.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), | |
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), | |
label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 10), | |
label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -10) | |
]) | |
} | |
func configure(with model: MovieGroup.Item) { | |
label.text = model.title | |
favouriteButton.tag = model.isFavourite ? 2 : 1 | |
favouriteButton.setImage(model.isFavourite ? .remove : .add, for: .normal) | |
} | |
@objc private func tappedFavourite() { | |
favouriteButton.tag = favouriteButton.tag == 1 ? 2 : 1 | |
favouriteButton.setImage(favouriteButton.tag == 2 ? .remove : .add, for: .normal) | |
onFavouriteTapped() | |
} | |
} | |
extension Section { | |
static func movieGroup( | |
_ movieGroup: Identified<MovieGroup>, | |
onTapped: @escaping (Identifier<MovieGroup>) -> Void, | |
onItemTapped: @escaping (Identifier<MovieGroup.Item>) -> Void, | |
onItemFavouriteTapped: @escaping (Identifier<MovieGroup.Item>) -> Void | |
) -> Section { | |
.init(reusableView: { kind, collectionView, indexPath in | |
.init(frame: .zero) | |
}, cells: movieGroup.content.items.map { item in | |
.movie( | |
item, | |
onTapped: onItemTapped, | |
onFavouriteTapped: onItemFavouriteTapped | |
) | |
}) | |
} | |
} | |
extension Cell { | |
static func movie( | |
_ movie: Identified<MovieGroup.Item>, | |
onTapped: @escaping (Identifier<MovieGroup.Item>) -> Void, | |
onFavouriteTapped: @escaping (Identifier<MovieGroup.Item>) -> Void | |
) -> Cell { | |
.init { collectionView, indexPath in | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MovieCell", for: indexPath) as? MovieCell else { | |
return .init() | |
} | |
cell.configure(with: movie.content) | |
cell.onFavouriteTapped = { | |
onFavouriteTapped(movie.id) | |
} | |
return cell | |
} tapped: { | |
onTapped(movie.id) | |
} | |
} | |
} | |
class MovieGroupDataSource: ContentDataSourceProtocol { | |
enum NavigationTarget { | |
case showMovieGroup(Identifier<MovieGroup>) | |
case showMovie(Identifier<MovieGroup.Item>) | |
} | |
var onUpdate: () -> Void = {} | |
var layoutSection: NSCollectionLayoutSection { | |
let layoutSection = makeLayoutSection( | |
insets: .init(top: Style.spacing, leading: Style.spacing, bottom: 0, trailing: Style.spacing), | |
size: .init(width: 160, height: 240) | |
) | |
return layoutSection | |
} | |
private var onNavigate: (NavigationTarget) -> Void | |
private var movieGroup: Identified<MovieGroup> | |
init(onNavigate: @escaping (NavigationTarget) -> Void) { | |
self.onNavigate = onNavigate | |
self.movieGroup = Identified<MovieGroup>( | |
id: "mv1", | |
content: MovieGroup( | |
items: [ | |
Identified<MovieGroup.Item>.init( | |
id: "mv1_it1", | |
content: .init( | |
title: "Movie1", | |
isFavourite: false | |
) | |
), | |
Identified<MovieGroup.Item>.init( | |
id: "mv1_it2", | |
content: .init( | |
title: "Movie2", | |
isFavourite: true | |
) | |
), | |
Identified<MovieGroup.Item>.init( | |
id: "mv1_it3", | |
content: .init( | |
title: "Movie3", | |
isFavourite: false | |
) | |
) | |
] | |
) | |
) | |
} | |
func register(in collectionView: UICollectionView) { | |
collectionView.register(MovieCell.self, forCellWithReuseIdentifier: "MovieCell") | |
} | |
var section: Section { | |
Section.movieGroup(movieGroup) { [weak self] id in | |
self?.onNavigate(.showMovieGroup(id)) | |
} onItemTapped: { [weak self] id in | |
self?.onNavigate(.showMovie(id)) | |
} onItemFavouriteTapped: { [weak self] id in | |
self?.toggleFavourite(id) | |
} | |
} | |
private func toggleFavourite(_ id: Identifier<MovieGroup.Item>) { | |
movieGroup = .init( | |
id: movieGroup.id, | |
content: MovieGroup( | |
items: movieGroup.content.items.map { match in | |
guard match.id == id else { | |
return match | |
} | |
print("Toggled movie \(id) to \(!match.content.isFavourite)") | |
return .init( | |
id: id, | |
content: .init( | |
title: match.content.title, | |
isFavourite: !match.content.isFavourite | |
) | |
) | |
} | |
) | |
) | |
onUpdate() | |
} | |
} | |
// MARK: - TvShowGroup | |
struct TvShowGroup: Equatable { | |
let title: String | |
let items: [Identified<Item>] | |
struct TvShow: Equatable { | |
let title: String | |
var isFavourite: Bool | |
} | |
enum Item: Equatable { | |
case tvShow(TvShow) | |
case viewAll | |
} | |
} | |
class ViewAllCell: UICollectionViewCell { | |
private lazy var label = UILabel() | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
contentView.backgroundColor = .lightGray | |
contentView.layer.cornerRadius = 10 | |
contentView.layer.masksToBounds = true | |
label.text = "View All" | |
label.textColor = .white | |
label.font = .boldSystemFont(ofSize: 16) | |
label.shadowColor = .black | |
label.shadowOffset = .init(width: 2, height: 2) | |
label.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), | |
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), | |
label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 10), | |
label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -10) | |
]) | |
} | |
} | |
class TvShowCell: UICollectionViewCell { | |
private lazy var label = UILabel() | |
private lazy var favouriteButton = UIButton() | |
var onFavouriteTapped: () -> Void = {} | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
contentView.backgroundColor = .blue | |
contentView.layer.cornerRadius = 10 | |
contentView.layer.masksToBounds = true | |
favouriteButton.addTarget(self, action: #selector(tappedFavourite), for: .touchUpInside) | |
favouriteButton.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(favouriteButton) | |
NSLayoutConstraint.activate([ | |
favouriteButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2), | |
favouriteButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -2), | |
favouriteButton.heightAnchor.constraint(equalToConstant: 44), | |
favouriteButton.widthAnchor.constraint(equalToConstant: 44) | |
]) | |
label.textColor = .white | |
label.font = .boldSystemFont(ofSize: 16) | |
label.shadowColor = .black | |
label.shadowOffset = .init(width: 2, height: 2) | |
label.translatesAutoresizingMaskIntoConstraints = false | |
contentView.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), | |
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), | |
label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -10) | |
]) | |
} | |
func configure(with model: TvShowGroup.TvShow) { | |
label.text = model.title | |
favouriteButton.tag = model.isFavourite ? 2 : 1 | |
favouriteButton.setImage(model.isFavourite ? .remove : .add, for: .normal) | |
} | |
@objc private func tappedFavourite() { | |
favouriteButton.tag = favouriteButton.tag == 1 ? 2 : 1 | |
favouriteButton.setImage(favouriteButton.tag == 2 ? .remove : .add, for: .normal) | |
onFavouriteTapped() | |
} | |
} | |
class TvShowHeader: UICollectionReusableView { | |
private lazy var titleLabel = UILabel() | |
override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
backgroundColor = Style.backgroundColor | |
titleLabel.textColor = .lightGray | |
titleLabel.font = .boldSystemFont(ofSize: 14) | |
titleLabel.translatesAutoresizingMaskIntoConstraints = false | |
addSubview(titleLabel) | |
NSLayoutConstraint.activate([ | |
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Style.spacing), | |
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Style.spacing), | |
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Style.spacing), | |
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Style.spacing), | |
]) | |
} | |
func configure(with group: TvShowGroup) { | |
titleLabel.text = group.title + " (\(group.items.count))" | |
} | |
} | |
extension Cell { | |
static func tvShow( | |
_ tvShow: Identified<TvShowGroup.Item>, | |
onTapped: @escaping (Identifier<TvShowGroup.Item>) -> Void, | |
onFavouriteTapped: @escaping (Identifier<TvShowGroup.Item>) -> Void | |
) -> Cell { | |
.init { collectionView, indexPath in | |
switch tvShow.content { | |
case .tvShow(let item): | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TvShowCell", for: indexPath) as? TvShowCell else { | |
return .init() | |
} | |
cell.configure(with: item) | |
cell.onFavouriteTapped = { | |
onFavouriteTapped(tvShow.id) | |
} | |
return cell | |
case .viewAll: | |
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ViewAllCell", for: indexPath) as? ViewAllCell else { | |
return .init() | |
} | |
return cell | |
} | |
} tapped: { | |
onTapped(tvShow.id) | |
} | |
} | |
} | |
extension Section { | |
static func tvShowGroup( | |
_ tvShowGroup: Identified<TvShowGroup>, | |
onTapped: @escaping (Identifier<TvShowGroup>) -> Void, | |
onItemTapped: @escaping (Identifier<TvShowGroup.Item>) -> Void, | |
onItemFavouriteTapped: @escaping (Identifier<TvShowGroup.Item>) -> Void | |
) -> Section { | |
.init(reusableView: { kind, collectionView, indexPath in | |
guard let tvShowHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "TvShowHeader", for: indexPath) as? TvShowHeader else { | |
return .init(frame: .zero) | |
} | |
tvShowHeader.configure(with: tvShowGroup.content) | |
return tvShowHeader | |
}, cells: tvShowGroup.content.items.map { item in | |
.tvShow( | |
item, | |
onTapped: onItemTapped, | |
onFavouriteTapped: onItemFavouriteTapped | |
) | |
}) | |
} | |
} | |
class TvShowGroupDataSource: ContentDataSourceProtocol { | |
enum NavigationTarget { | |
case showTvShowGroup(Identifier<TvShowGroup>) | |
case showTvShow(Identifier<TvShowGroup.Item>) | |
} | |
var onUpdate: () -> Void = { } | |
var layoutSection: NSCollectionLayoutSection { | |
let layoutSection = makeLayoutSection( | |
insets: .init(top: 0, leading: Style.spacing, bottom: Style.spacing, trailing: Style.spacing), | |
size: .init(width: 240, height: 160) | |
) | |
layoutSection.boundarySupplementaryItems = [ | |
.init( | |
layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44)), | |
elementKind: "Header", | |
alignment: .top | |
) | |
] | |
return layoutSection | |
} | |
private var onNavigate: (NavigationTarget) -> Void | |
private var tvShowGroup: Identified<TvShowGroup> | |
init(onNavigate: @escaping (NavigationTarget) -> Void) { | |
self.onNavigate = onNavigate | |
self.tvShowGroup = Identified<TvShowGroup>( | |
id: "sr1", | |
content: TvShowGroup( | |
title: "TvShowGroup", | |
items: [ | |
Identified<TvShowGroup.Item>.init( | |
id: "sr1_it1", | |
content: .tvShow( | |
.init( | |
title: "TvShow1", | |
isFavourite: true | |
) | |
) | |
), | |
Identified<TvShowGroup.Item>.init( | |
id: "sr1_it2", | |
content: .tvShow( | |
.init( | |
title: "TvShow2", | |
isFavourite: false | |
) | |
) | |
), | |
Identified<TvShowGroup.Item>.init( | |
id: "sr1_it3", | |
content: .tvShow( | |
.init( | |
title: "TvShow3", | |
isFavourite: false | |
) | |
) | |
), | |
Identified<TvShowGroup.Item>.init( | |
id: "sr1_view_all", | |
content: .viewAll | |
) | |
] | |
) | |
) | |
} | |
func register(in collectionView: UICollectionView) { | |
collectionView.register(TvShowHeader.self, forSupplementaryViewOfKind: "Header", withReuseIdentifier: "TvShowHeader") | |
collectionView.register(TvShowCell.self, forCellWithReuseIdentifier: "TvShowCell") | |
collectionView.register(ViewAllCell.self, forCellWithReuseIdentifier: "ViewAllCell") | |
} | |
var section: Section { | |
Section.tvShowGroup(tvShowGroup) { [weak self] id in | |
self?.onNavigate(.showTvShowGroup(id)) | |
} onItemTapped: { [weak self] id in | |
self?.onNavigate(.showTvShow(id)) | |
} onItemFavouriteTapped: { [weak self] id in | |
self?.toggleFavourite(id) | |
} | |
} | |
private func toggleFavourite(_ id: Identifier<TvShowGroup.Item>) { | |
tvShowGroup = .init( | |
id: tvShowGroup.id, | |
content: TvShowGroup( | |
title: tvShowGroup.content.title, | |
items: tvShowGroup.content.items.map { match in | |
guard match.id == id else { | |
return match | |
} | |
switch match.content { | |
case .viewAll: return match | |
case .tvShow(let show): | |
print("Toggled series \(id) to \(!show.isFavourite)") | |
return .init( | |
id: id, | |
content: .tvShow( | |
.init( | |
title: show.title, | |
isFavourite: show.isFavourite | |
) | |
) | |
) | |
} | |
} | |
) | |
) | |
onUpdate() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment