ListDiffUI
This commit is contained in:
parent
4b8052eff5
commit
d788fa44fc
|
@ -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",
|
||||
],
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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?()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
35
README.md
35
README.md
|
@ -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.
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import Foundation
|
||||
|
||||
public protocol ListViewState {
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
public struct ListViewStateNone: ListViewState {
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"lineLength" : 120,
|
||||
"tabWidth" : 2,
|
||||
"version" : 1
|
||||
}
|
||||
|
Loading…
Reference in New Issue