ListDiffUI

This commit is contained in:
Siyu Yue 2022-12-17 09:22:36 -08:00
parent 4b8052eff5
commit d788fa44fc
19 changed files with 1081 additions and 20 deletions

View File

@ -8,6 +8,7 @@ load(
swift_library(
name = "SampleAppLib",
module_name = "SampleAppLib",
srcs = glob([
"SampleApp/**/*.swift",
]),
@ -27,7 +28,7 @@ ios_application(
"SampleApp/Info.plist",
],
launch_storyboard = "SampleApp/LaunchScreen.storyboard",
minimum_os_version = "13.0",
minimum_os_version = "14.0",
deps = [
":SampleAppLib",
],

View File

@ -3,16 +3,19 @@ import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
func application(
_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Override point for customization after application launch.
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
func application(
_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
@ -23,7 +26,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}

View File

@ -0,0 +1,56 @@
import ListDiffUI
import UIKit
struct ButtonViewModel: ListViewModel & Equatable {
var identifier: String {
title
}
var title: String
@EquatableNoop
var didTap: (() -> Void)?
}
final class ButtonCell: ListCell {
lazy var button: UIButton = {
let button = UIButton()
contentView.addSubview(button)
button.frame = contentView.bounds
button.backgroundColor = .blue
return button
}()
func configure(viewModel: ButtonViewModel, viewState: ListViewStateNone) {
button.setTitle(viewModel.title, for: .normal)
}
}
final class ButtonCellController: ListCellController<
ButtonViewModel,
ListViewStateNone,
ButtonCell
>
{
override func itemSize(containerSize: CGSize) -> CGSize {
return CGSize(width: 120, height: 30)
}
override func configureCell(cell: ButtonCell) {
cell.button.setTitle(viewModel.title, for: .normal)
}
override func didMount(onCell cell: ButtonCell) {
cell.button.removeTarget(nil, action: nil, for: .allEvents)
cell.button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
}
@objc
private func didTap() {
context.object(type: HelloWorldLogger.self)?.log(message: "Button Tapped")
viewModel.didTap?()
}
}

View File

@ -0,0 +1,126 @@
import ListDiffUI
import UIKit
struct LabelViewModel: ListViewModel, Equatable {
var identifier: String
var item: String
var count: Int
}
struct LabelViewState: ListViewState {
var expanded = false
}
final class LabelCell: ListCell {
lazy var label: UILabel = {
let label = UILabel()
contentView.addSubview(label)
contentView.backgroundColor = .lightGray
return label
}()
lazy var expandButton: UIButton = {
let button = UIButton()
contentView.addSubview(button)
button.backgroundColor = .green
return button
}()
lazy var plusButton: UIButton = {
let button = UIButton()
contentView.addSubview(button)
button.backgroundColor = .green
button.setTitle("+", for: .normal)
return button
}()
lazy var minusButton: UIButton = {
let button = UIButton()
contentView.addSubview(button)
button.backgroundColor = .green
button.setTitle("-", for: .normal)
return button
}()
}
protocol LabelCellControllerDelegate {
func didTapPlus(viewModel: LabelViewModel)
func didTapMinus(viewModel: LabelViewModel)
}
final class LabelViewCellController: ListCellController<
LabelViewModel,
LabelViewState,
LabelCell
>, ListCellControllerWithDelegate
{
typealias DelegateType = LabelCellControllerDelegate
override func itemSize(containerSize: CGSize) -> CGSize {
return CGSize(width: containerSize.width, height: viewState.expanded ? 120 : 60)
}
override func configureCell(cell: LabelCell) {
cell.label.text = "\(viewModel.item) x \(viewModel.count)"
cell.expandButton.setTitle(viewState.expanded ? "Collapse" : "Expand", for: .normal)
cell.minusButton.isHidden = viewModel.count == 0
}
override func cellDidLayoutSubviews(cell: LabelCell) {
cell.label.frame = CGRect(x: 16, y: cell.contentView.bounds.midY - 10, width: 100, height: 20)
cell.expandButton.frame = CGRect(
x: cell.contentView.bounds.maxX - 96, y: cell.label.frame.minY, width: 80, height: 20)
cell.minusButton.frame = CGRect(
x: cell.expandButton.frame.minX - 36, y: cell.label.frame.minY, width: 20, height: 20)
cell.plusButton.frame = CGRect(
x: cell.minusButton.frame.minX - 36, y: cell.label.frame.minY, width: 20, height: 20)
}
override func didMount(onCell cell: LabelCell) {
cell.expandButton.removeTarget(nil, action: nil, for: .touchUpInside)
cell.expandButton.addTarget(self, action: #selector(didTapExpand), for: .touchUpInside)
cell.minusButton.removeTarget(nil, action: nil, for: .touchUpInside)
cell.minusButton.addTarget(self, action: #selector(didTapMinus), for: .touchUpInside)
cell.plusButton.removeTarget(nil, action: nil, for: .touchUpInside)
cell.plusButton.addTarget(self, action: #selector(didTapPlus), for: .touchUpInside)
}
override func didAppear() {
context.object(type: HelloWorldLogger.self)?.log(message: "\(viewModel.identifier) didAppear")
}
override func willDisappear() {
context.object(type: HelloWorldLogger.self)?.log(
message: "\(viewModel.identifier) willDisappear")
}
override func didFullyAppear() {
context.object(type: HelloWorldLogger.self)?.log(
message: "\(viewModel.identifier) didFullyAppear")
}
override func willPartiallyDisappear() {
context.object(type: HelloWorldLogger.self)?.log(
message: "\(viewModel.identifier) willPartiallyDisappear")
}
@objc
private func didTapExpand() {
var state = viewState
state.expanded = !state.expanded
updateState(state)
}
@objc
private func didTapPlus() {
delegate?.didTapPlus(viewModel: viewModel)
}
@objc
private func didTapMinus() {
delegate?.didTapMinus(viewModel: viewModel)
}
}

View File

@ -4,7 +4,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
func scene(
_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions
) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
@ -43,6 +45,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// to restore the scene back to its current state.
}
}

View File

@ -1,11 +1,107 @@
import ListDiffUI
import UIKit
class ViewController: UIViewController {
final class ViewController: UIViewController {
private lazy var collectionView: UICollectionView = {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
return collectionView
}()
private lazy var dataSource: ListDiffDataSource = {
let dataSource = ListDiffDataSource(
collectionView: collectionView, appleUpdatesAsync: true, contextObjects: logger)
return dataSource
}()
private struct ItemWithCount {
var id: String
var item: String
var count: Int
}
private var items: [ItemWithCount] = [] {
didSet {
updateSectionDataSource()
}
}
private let logger = HelloWorldLogger()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.backgroundColor = .white
view.addSubview(collectionView)
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
items = []
}
private func updateSectionDataSource() {
dataSource.setRootSection(
CompositeSection(
ListSection<
ItemWithCount, LabelViewCellController
>(items, delegate: .init(object: self)) {
LabelViewModel(identifier: $0.id, item: $0.item, count: $0.count)
},
ListSection<
ButtonViewModel, ButtonCellController
>(
ButtonViewModel(
title: "Add More",
didTap: { [weak self] in
guard let self = self else { return }
self.addMore()
})
),
ListSection<
ButtonViewModel, ButtonCellController
>(
ButtonViewModel(
title: "Double All",
didTap: { [weak self] in
guard let self = self else { return }
self.items = self.items.map {
ItemWithCount(id: $0.id, item: $0.item, count: $0.count * 2)
}
})
)
))
}
private func addMore() {
items.append(ItemWithCount(id: "\(items.count)", item: "Item \(items.count)", count: 1))
}
}
extension ViewController: LabelCellControllerDelegate {
func didTapPlus(viewModel: LabelViewModel) {
items = items.map({ item in
if item.id == viewModel.identifier {
return ItemWithCount(id: item.id, item: item.item, count: item.count + 1)
}
return item
})
}
func didTapMinus(viewModel: LabelViewModel) {
items = items.map({ item in
if item.id == viewModel.identifier {
return ItemWithCount(id: item.id, item: item.item, count: item.count - 1)
}
return item
})
}
}
final class HelloWorldLogger {
func log(message: String) {
NSLog(message)
}
}

View File

@ -1,2 +1,35 @@
# ListDiffUI
A descriptive, diffable data source for UICollectionView
A descriptive, diffable data source for UICollectionView.
## Features
### MVVMC Architechture
ListDiffUI employs Model-View-ViewModel-Controller architechture for each cell in the list.
### Uni-directional Dataflow
Data flows in one direction in ListDiffUI. Each data mutation should update Model first, and then update ViewModel. This greatly reduces potentional data inconsitency (and crashes) between model and view.
### Descriptive
Describe the structure of the list, with sections:
```
dataSource.setRootSection(
CompositeSection(
ListSection<
Bool, LoadingSpinnerController
>(isLoading) {
$0 ? LoadingSpinnerViewModel() : nil
},
ListSection<
ItemViewModel, ItemCellController
>(items)
)
)
```
### Diff updates
ListDiffUI internally uses the [ListDiff](https://github.com/lxcid/ListDiff) algorithm to compute diff and perform batch updates on the collection view.

View File

@ -0,0 +1,18 @@
import UIKit
open class ListCell: UICollectionViewCell {
class var reuseIdentifier: String {
String(describing: self)
}
weak var controller: AnyListCellController?
open override func layoutSubviews() {
super.layoutSubviews()
controller?.cellDidLayoutSubviews()
}
public override func prepareForReuse() {
}
}

View File

@ -0,0 +1,242 @@
import UIKit
public protocol ListCellControllerWithDelegate {
associatedtype DelegateType
var delegate: DelegateType? { get }
}
extension ListCellControllerWithDelegate where Self: AnyListCellController {
public var delegate: DelegateType? {
anyDelegate.object as? DelegateType
}
}
public final class WeakAnyObject {
public static var none = WeakAnyObject(object: nil)
weak var object: AnyObject?
public init(object: AnyObject?) {
self.object = object
}
}
public protocol ListCellControllerViewModelProviding {
associatedtype ListViewModelType: ListViewModel
}
open class ListCellController<
ListViewModelType: ListViewModel & Equatable,
ListViewStateType: ListViewState,
ListCellType: ListCell
>: AnyListCellController {
public private(set) var viewModel: ListViewModelType
public private(set) var viewState: ListViewStateType
override class var cellType: ListCell.Type {
ListCellType.self
}
private var updatingViewModel = false
private var invalidatingLayout = false
private var didUpdateCellWhenInvalidatingLayout = false
required public init(
viewModel: ListViewModel,
delegate: WeakAnyObject,
context: ListDiffContext
) {
let viewModel = viewModel as! ListViewModelType
self.viewModel = viewModel
self.viewState = ListViewStateType.init()
super.init(viewModel: viewModel, delegate: delegate, context: context)
}
public func updateState(_ viewState: ListViewStateType) {
self.viewState = viewState
if !updatingViewModel {
updateCell(shouldInvalidateLayout: true)
}
}
open override func itemSize(containerSize: CGSize) -> CGSize {
return .zero
}
open func configureCell(cell: ListCellType) {
}
open override func didAppear() {
}
open override func willDisappear() {
}
open override func didFullyAppear() {
}
open override func willPartiallyDisappear() {
}
open func didMount(onCell cell: ListCellType) {
}
open func willUnmount(onCell cell: ListCellType) {
}
open func didUpdateViewModel(oldViewModel: ListViewModelType) {
}
open func cellDidLayoutSubviews(cell: ListCellType) {
}
override func viewModelUntyped() -> ListViewModel {
viewModel
}
override func viewStateUntyped() -> ListViewState {
viewState
}
override func didMountInternal(onCell cell: ListCell) {
let cell = cell as! ListCellType
updateCell(shouldInvalidateLayout: false)
didMount(onCell: cell)
}
override func willUnmountInternal(onCell cell: ListCell) {
let cell = cell as! ListCellType
willUnmount(onCell: cell)
}
override func setViewModelInternal(viewModel: ListViewModel) {
let viewModel = viewModel as! ListViewModelType
guard viewModel != self.viewModel else {
return
}
let oldViewModel = self.viewModel
self.viewModel = viewModel
updatingViewModel = true
didUpdateViewModel(oldViewModel: oldViewModel)
updatingViewModel = false
updateCell(shouldInvalidateLayout: true)
}
override func cellDidLayoutSubviews() {
if invalidatingLayout {
updateCell(shouldInvalidateLayout: false)
didUpdateCellWhenInvalidatingLayout = true
}
guard let cell = cell else {
return
}
cellDidLayoutSubviews(cell: cell as! ListCellType)
}
func updateCell(shouldInvalidateLayout: Bool) {
guard let cell = cell else {
return
}
if shouldInvalidateLayout {
invalidatingLayout = true
didUpdateCellWhenInvalidatingLayout = false
layoutInvalidateHandler?(cell)
invalidatingLayout = false
if !didUpdateCellWhenInvalidatingLayout {
configureCell(cell: cell as! ListCellType)
}
} else {
configureCell(cell: cell as! ListCellType)
}
}
}
extension ListCellController: ListCellControllerViewModelProviding {
public typealias ListViewModelType = ListViewModelType
}
open class AnyListCellController {
class var cellType: ListCell.Type {
fatalError("Must be provided by subclass")
}
public let context: ListDiffContext
weak var cell: ListCell? {
willSet {
if let cell = cell {
willUnmountInternal(onCell: cell)
}
}
didSet {
if let cell = cell {
didMountInternal(onCell: cell)
}
}
}
var anyDelegate: WeakAnyObject
var layoutInvalidateHandler: ((UICollectionViewCell) -> Void)?
required public init(
viewModel: ListViewModel,
delegate: WeakAnyObject,
context: ListDiffContext
) {
self.anyDelegate = delegate
self.context = context
}
open func itemSize(containerSize: CGSize) -> CGSize {
fatalError()
}
open func didAppear() {
fatalError()
}
open func willDisappear() {
fatalError()
}
open func didFullyAppear() {
fatalError()
}
open func willPartiallyDisappear() {
fatalError()
}
func viewModelUntyped() -> ListViewModel {
fatalError()
}
func viewStateUntyped() -> ListViewState {
fatalError()
}
func didMountInternal(onCell cell: ListCell) {
fatalError()
}
func willUnmountInternal(onCell cell: ListCell) {
fatalError()
}
func setViewModelInternal(viewModel: ListViewModel) {
fatalError()
}
func cellDidLayoutSubviews() {
fatalError()
}
}

View File

@ -0,0 +1,24 @@
import Foundation
public final class ListDiffContext {
private let objects: [ObjectIdentifier: AnyObject]
public init(objects: [AnyObject]) {
var objectsDictionary: [ObjectIdentifier: AnyObject] = [:]
for object in objects {
let identifier = ObjectIdentifier(type(of: object))
precondition(objectsDictionary[identifier] == nil)
objectsDictionary[identifier] = object
}
self.objects = objectsDictionary
}
public func object<T>(type: T.Type) -> T? {
return objects[ObjectIdentifier(type)] as? T
}
public func object<T>() -> T? {
return objects[ObjectIdentifier(T.self)] as? T
}
}

View File

@ -0,0 +1,275 @@
import ListDiff
import UIKit
private let cellAppearanceUpdateInterval: CFTimeInterval = 0.1
/// ListDiffDataSource
///
/// DataSource object to provide data to UICollectionView.
///
/// UICollectionView's layout must be an instance of UICollectionFlowLayout (or its subclass), as it implements
/// func collectionView(_: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath)
/// of UICollectionViewDelegateFlowLayout.
///
/// ListDiffDataSource sets itself as both delegate and dataSource of the UICollectionView. If you need to be the
/// delegate of the UICollectionView as well, set your instance as the collectionViewDelegate of the
/// ListDiffDataSource object instead.
///
/// appleUpdatesAsync: Set this to true on init to perform diffing and UI updates off main queue. Note that this will
/// cause SectionComponent's build() calls to happen off main queue as well.
public final class ListDiffDataSource: NSObject {
public weak var collectionViewDelegate: UICollectionViewDelegate?
private let collectionView: UICollectionView
private let context: ListDiffContext
private let queue: DispatchQueue
private let appleUpdatesAsync: Bool
private var appearedCellIdentifiers = Set<String>()
private var fullyAppearedCellIdentifiers = Set<String>()
private var cellControllers: [String: AnyListCellController] = [:]
private var lastAppearanceUpdateTime: CFTimeInterval = .zero
private var registeredReuseIdentifiers = Set<String>()
private var viewDataModels: [ListDiffDataModel] = []
private var viewDataModelsOnQueue: [ListDiffDataModel] = []
public init(collectionView: UICollectionView, appleUpdatesAsync: Bool = false, contextObjects: AnyObject...) {
precondition(collectionView.collectionViewLayout is UICollectionViewFlowLayout)
self.collectionView = collectionView
self.appleUpdatesAsync = appleUpdatesAsync
self.context = ListDiffContext(objects: contextObjects)
queue = appleUpdatesAsync ? DispatchQueue(label: "ListDiffui.ListDiffdatasource", qos: .default) : .main
super.init()
collectionView.dataSource = self
collectionView.delegate = self
}
public func setRootSection(_ section: Section?, animate: Bool = false, completion: (() -> Void)? = nil) {
if appleUpdatesAsync {
queue.async {
let diff = self.computeDiff(section: section)
DispatchQueue.main.async {
self.applyUpdates(diffResult: diff.0, newDataModels: diff.1, animate: animate)
completion?()
}
}
} else {
let diff = computeDiff(section: section)
applyUpdates(diffResult: diff.0, newDataModels: diff.1, animate: animate)
completion?()
}
}
private func computeDiff(section: Section?) -> (List.Result, [ListDiffDataModel]) {
dispatchPrecondition(condition: .onQueue(queue))
let newDataModels = section?.build() ?? []
let diffResult = List.diffing(
oldArray: self.viewDataModelsOnQueue,
newArray: newDataModels)
self.viewDataModelsOnQueue = newDataModels
return (diffResult, newDataModels)
}
private func applyUpdates(diffResult: List.Result, newDataModels: [ListDiffDataModel], animate: Bool) {
dispatchPrecondition(condition: .onQueue(.main))
guard diffResult.changeCount > 0 else {
return
}
// Apply diff updates.
var identifiersToDelete = Set<String>()
diffResult.deletes.forEach { i in
identifiersToDelete.insert(self.viewDataModels[i].identifier)
}
diffResult.updates.forEach { index in
let identifier = self.viewDataModels[index].identifier
guard let controller = self.cellControllers[identifier],
let newIndex = diffResult.newIndexFor(identifier: identifier)
else {
fatalError()
}
controller.setViewModelInternal(viewModel: newDataModels[newIndex].viewModel)
}
let batchUpdates = {
self.collectionView.performBatchUpdates {
self.viewDataModels = newDataModels
self.collectionView.deleteItems(
at: diffResult.deletes.map { IndexPath(item: $0, section: 0) })
self.collectionView.insertItems(
at: diffResult.inserts.map { IndexPath(item: $0, section: 0) })
for move in diffResult.moves {
self.collectionView.moveItem(
at: IndexPath(item: move.from, section: 0),
to: IndexPath(item: move.to, section: 0))
}
}
}
if animate {
UIView.performWithoutAnimation(batchUpdates)
} else {
batchUpdates()
}
identifiersToDelete.forEach { cellControllers[$0] = nil }
updateCellAppearance()
}
private func cellController(_ viewDataModel: ListDiffDataModel)
-> AnyListCellController
{
if let controller = cellControllers[viewDataModel.identifier] {
return controller
}
let controller = viewDataModel.controllerType.init(
viewModel: viewDataModel.viewModel,
delegate: viewDataModel.delegate,
context: context
)
controller.layoutInvalidateHandler = { [weak self] cell in
guard let self = self, let indexPath = self.collectionView.indexPath(for: cell) else {
return
}
let layoutInvalidation = UICollectionViewFlowLayoutInvalidationContext()
layoutInvalidation.invalidateItems(at: [indexPath])
self.collectionView.performBatchUpdates {
self.collectionView.collectionViewLayout.invalidateLayout(with: layoutInvalidation)
}
}
cellControllers[viewDataModel.identifier] = controller
return controller
}
private func updateCellAppearance() {
var newAppearedCellIdentifiers = Set<String>()
var newFullyAppearedCellIdentifiers = Set<String>()
let contentRect = collectionView.bounds
guard contentRect.size.width > 0 && contentRect.size.height > 0 else {
return
}
collectionView.visibleCells.forEach { cell in
guard let index = collectionView.indexPath(for: cell)?.item else {
return
}
if contentRect.intersects(cell.frame) {
newAppearedCellIdentifiers.insert(self.viewDataModels[index].identifier)
}
if contentRect.contains(cell.frame) {
newFullyAppearedCellIdentifiers.insert(self.viewDataModels[index].identifier)
}
}
for identifier in fullyAppearedCellIdentifiers.subtracting(newFullyAppearedCellIdentifiers) {
cellControllers[identifier]?.willPartiallyDisappear()
}
for identifier in appearedCellIdentifiers.subtracting(newAppearedCellIdentifiers) {
cellControllers[identifier]?.willDisappear()
}
for identifier in newAppearedCellIdentifiers.subtracting(appearedCellIdentifiers) {
cellControllers[identifier]?.didAppear()
}
for identifier in newFullyAppearedCellIdentifiers.subtracting(fullyAppearedCellIdentifiers) {
cellControllers[identifier]?.didFullyAppear()
}
appearedCellIdentifiers = newAppearedCellIdentifiers
fullyAppearedCellIdentifiers = newFullyAppearedCellIdentifiers
}
}
extension ListDiffDataSource: UICollectionViewDataSource {
public func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection ListDiff: Int
) -> Int {
return viewDataModels.count
}
public func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let viewDataModel = viewDataModels[indexPath.item]
let reuseIdentifier = viewDataModel.controllerType.cellType.reuseIdentifier
if !registeredReuseIdentifiers.contains(reuseIdentifier) {
registeredReuseIdentifiers.insert(reuseIdentifier)
collectionView.register(viewDataModel.controllerType.cellType, forCellWithReuseIdentifier: reuseIdentifier)
}
let cell =
collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
as! ListCell
let controller = cellController(viewDataModel)
if cell.controller !== controller {
cell.controller?.cell = nil
cell.controller = controller
}
if controller.cell !== cell {
controller.cell?.controller = nil
controller.cell = cell
}
controller.setViewModelInternal(viewModel: viewDataModel.viewModel)
return cell
}
}
extension ListDiffDataSource: UICollectionViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
let time = CACurrentMediaTime()
if time - lastAppearanceUpdateTime > cellAppearanceUpdateInterval {
lastAppearanceUpdateTime = time
updateCellAppearance()
}
collectionViewDelegate?.scrollViewDidScroll?(scrollView)
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
updateCellAppearance()
collectionViewDelegate?.scrollViewWillBeginDragging?(scrollView)
}
public func scrollViewDidEndDragging(
_ scrollView: UIScrollView,
willDecelerate decelerate: Bool
) {
updateCellAppearance()
collectionViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCellAppearance()
collectionViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
}
}
extension ListDiffDataSource: UICollectionViewDelegateFlowLayout {
public func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let viewDataModel = viewDataModels[indexPath.item]
let controller = cellController(viewDataModel)
return controller.itemSize(
containerSize: collectionView.frame.inset(
by: collectionView.adjustedContentInset
).size)
}
}
extension ListDiffDataModel: Equatable {
static func == (lhs: ListDiffDataModel, rhs: ListDiffDataModel) -> Bool {
return lhs.diffIdentifier == rhs.diffIdentifier
&& lhs.viewModel.isEqual(to: rhs.viewModel)
}
}
extension ListDiffDataModel: Diffable {
var diffIdentifier: AnyHashable {
identifier
}
}

126
Sources/ListSection.swift Normal file
View File

@ -0,0 +1,126 @@
import Foundation
struct ListDiffDataModel {
var identifier: String
var viewModel: ListViewModel
var delegate: WeakAnyObject
var controllerType: AnyListCellController.Type
}
/// Base class and available for subclassing.
open class Section {
var identifier: String {
String(describing: self)
}
func build() -> [ListDiffDataModel] {
fatalError("Subclass must provide its own implementation")
}
}
/// Supports composing multiple Sections.
public final class CompositeSection: Section {
private let s: [Section?]
public init(_ s: Section?...) {
self.s = s
}
override func build() -> [ListDiffDataModel] {
return s.enumerated().flatMap { (index, section) -> [ListDiffDataModel] in
guard let section = section else {
return []
}
return section.build().map { v in
var viewDataModel = v
viewDataModel.identifier = [section.identifier, String(index), v.identifier].joined(
separator: "~")
return viewDataModel
}
}
}
}
/// Supports building a list of homogeneous cells.
public final class ListSection<
T,
ListCellControllerType: AnyListCellController & ListCellControllerViewModelProviding
>: Section {
private let models: [T]
private let transform: (T) -> ListCellControllerType.ListViewModelType?
private let delegate: WeakAnyObject
public init(
_ models: [T], delegate: WeakAnyObject = .none,
transform: @escaping (T) -> ListCellControllerType.ListViewModelType?
) {
self.models = models
self.delegate = delegate
self.transform = transform
}
override func build() -> [ListDiffDataModel] {
return models.compactMap { transform($0) }.map {
ListDiffDataModel(
identifier: $0.identifier,
viewModel: $0,
delegate: delegate,
controllerType: ListCellControllerType.self
)
}
}
}
/// Convenience init for building a single cell.
extension ListSection {
public convenience init(
_ model: T, delegate: WeakAnyObject = .none,
transform: @escaping (T) -> ListCellControllerType.ListViewModelType?
) {
self.init([model], delegate: delegate, transform: transform)
}
}
/// Convenience init for when input type is ListViewModelType.
extension ListSection where T == ListCellControllerType.ListViewModelType {
public convenience init(_ model: T, delegate: WeakAnyObject = .none) {
self.init([model], delegate: delegate) { $0 }
}
public convenience init(_ models: [T], delegate: WeakAnyObject = .none) {
self.init(models, delegate: delegate) { $0 }
}
}
/// Supports building a list of heterogeneous cells.
public final class ListRenderSection<T: Identifiable>: Section {
private let models: [T]
private let transform: (T) -> Section?
public init(_ models: [T], transform: @escaping (T) -> Section?) {
self.models = models
self.transform = transform
}
override func build() -> [ListDiffDataModel] {
return models.flatMap { model -> [ListDiffDataModel] in
guard let section = transform(model) else {
return []
}
return section.build().map {
ListDiffDataModel(
identifier: [model.identifier, $0.identifier].joined(separator: "~"),
viewModel: $0.viewModel,
delegate: $0.delegate,
controllerType: $0.controllerType
)
}
}
}
}

View File

@ -0,0 +1,41 @@
import Foundation
public protocol Identifiable {
var identifier: String { get }
}
public protocol ListViewModel: Identifiable {
func isEqual(to: ListViewModel) -> Bool
}
extension ListViewModel where Self: Equatable {
public func isEqual(to: ListViewModel) -> Bool {
guard let other = to as? Self else { return false }
return self == other
}
}
public struct ListViewModelNone: ListViewModel, Equatable {
public var identifier: String {
"ListViewModelNone"
}
public static let none = ListViewModelNone()
}
@propertyWrapper public struct EquatableNoop<T>: Equatable {
public var wrappedValue: T
public init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
public static func == (lhs: EquatableNoop<T>, rhs: EquatableNoop<T>) -> Bool {
return true
}
}

View File

@ -0,0 +1,12 @@
import Foundation
public protocol ListViewState {
init()
}
public struct ListViewStateNone: ListViewState {
public init() {
}
}

View File

@ -27,10 +27,6 @@ load(
swift_rules_extra_dependencies()
load("//:repositories.bzl", "sectionui_dependencies")
sectionui_dependencies(is_local = True)
load(
"@build_bazel_rules_apple//apple:repositories.bzl",
"apple_rules_dependencies",
@ -44,3 +40,7 @@ load(
)
apple_support_dependencies()
load("//:repositories.bzl", "listdiffui_dependencies")
listdiffui_dependencies(is_local = True)

5
format.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
swift-format --configuration swift-format-config.json -i -r Sources
swift-format --configuration swift-format-config.json -i -r Examples

View File

@ -1,6 +1,6 @@
load("@bazel_tools//tools/build_defs/repo:git.bzl", "new_git_repository")
def sectionui_dependencies(is_local = False):
def listdiffui_dependencies(is_local = False):
new_git_repository(
name = "ListDiff",
remote = "https://github.com/lxcid/ListDiff.git",

6
swift-format-config.json Normal file
View File

@ -0,0 +1,6 @@
{
"lineLength" : 120,
"tabWidth" : 2,
"version" : 1
}