Add SampleApp2 (#3)
This commit is contained in:
parent
9c84e36de7
commit
c8cdb88be6
|
@ -42,3 +42,40 @@ xcodeproj(
|
|||
top_level_target(":SampleApp", target_environments = ["simulator"]),
|
||||
],
|
||||
)
|
||||
|
||||
swift_library(
|
||||
name = "SampleAppLib2",
|
||||
module_name = "SampleAppLib2",
|
||||
srcs = glob([
|
||||
"SampleApp2/**/*.swift",
|
||||
]),
|
||||
deps = [
|
||||
"//:ListDiffUI"
|
||||
],
|
||||
)
|
||||
|
||||
ios_application(
|
||||
name = "SampleApp2",
|
||||
bundle_id = "com.siyuyue.listdiffui.sampleapp",
|
||||
families = [
|
||||
"iphone",
|
||||
"ipad",
|
||||
],
|
||||
infoplists = [
|
||||
"SampleApp2/Info.plist",
|
||||
],
|
||||
launch_storyboard = "SampleApp2/LaunchScreen.storyboard",
|
||||
minimum_os_version = "14.0",
|
||||
deps = [
|
||||
":SampleAppLib2",
|
||||
],
|
||||
)
|
||||
|
||||
xcodeproj(
|
||||
name = "SampleAppProject2",
|
||||
project_name = "SampleApp2",
|
||||
build_mode = "bazel",
|
||||
top_level_targets = [
|
||||
top_level_target(":SampleApp2", target_environments = ["simulator"]),
|
||||
],
|
||||
)
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 873 KiB |
|
@ -22,10 +22,6 @@ final class ButtonCell: ListCell {
|
|||
button.backgroundColor = .blue
|
||||
return button
|
||||
}()
|
||||
|
||||
func configure(viewModel: ButtonViewModel, viewState: ListViewStateNone) {
|
||||
button.setTitle(viewModel.title, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
final class ButtonCellController: ListCellController<
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 671 KiB |
|
@ -0,0 +1,29 @@
|
|||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
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 {
|
||||
// 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)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// 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,49 @@
|
|||
import ListDiffUI
|
||||
import UIKit
|
||||
|
||||
struct EndOfListViewModel: ListViewModel & Equatable {
|
||||
|
||||
var identifier: String {
|
||||
"EndOfList"
|
||||
}
|
||||
}
|
||||
|
||||
final class EndOfListCell: ListCell {
|
||||
|
||||
lazy var button: UIButton = {
|
||||
let button = UIButton()
|
||||
contentView.addSubview(button)
|
||||
button.frame = CGRect(x: contentView.bounds.midX - 80, y: 5, width: 160, height: 30)
|
||||
button.backgroundColor = .blue
|
||||
button.setTitle("Load More", for: .normal)
|
||||
return button
|
||||
}()
|
||||
}
|
||||
|
||||
protocol EndOfListCellControllerDelegate {
|
||||
func didTapLoadMore()
|
||||
}
|
||||
|
||||
final class EndOfListCellController: ListCellController<
|
||||
EndOfListViewModel,
|
||||
ListViewStateNone,
|
||||
EndOfListCell
|
||||
>, ListCellControllerWithDelegate
|
||||
{
|
||||
|
||||
typealias DelegateType = EndOfListCellControllerDelegate
|
||||
|
||||
override func itemSize(containerSize: CGSize) -> CGSize {
|
||||
return CGSize(width: containerSize.width, height: 40)
|
||||
}
|
||||
|
||||
override func didMount(onCell cell: EndOfListCell) {
|
||||
cell.button.removeTarget(nil, action: nil, for: .allEvents)
|
||||
cell.button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func didTap() {
|
||||
delegate?.didTapLoadMore()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>SampleAppLib2.SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
|
@ -0,0 +1,44 @@
|
|||
import ListDiffUI
|
||||
import UIKit
|
||||
|
||||
struct LoadingSpinnerViewModel: ListViewModel, Equatable {
|
||||
|
||||
var identifier: String {
|
||||
"loadingSpinner"
|
||||
}
|
||||
|
||||
var height: CGFloat
|
||||
}
|
||||
|
||||
final class LoadingSpinnerCell: ListCell {
|
||||
|
||||
lazy var loadingSpinner: UIActivityIndicatorView = {
|
||||
var loadingSpinner = UIActivityIndicatorView()
|
||||
contentView.addSubview(loadingSpinner)
|
||||
return loadingSpinner
|
||||
}()
|
||||
}
|
||||
|
||||
final class LoadingSpinnerCellController: ListCellController<
|
||||
LoadingSpinnerViewModel,
|
||||
ListViewStateNone,
|
||||
LoadingSpinnerCell
|
||||
>
|
||||
{
|
||||
|
||||
override func itemSize(containerSize: CGSize) -> CGSize {
|
||||
return CGSize(width: containerSize.width, height: viewModel.height)
|
||||
}
|
||||
|
||||
override func cellDidLayoutSubviews(cell: LoadingSpinnerCell) {
|
||||
cell.loadingSpinner.frame = cell.contentView.bounds
|
||||
}
|
||||
|
||||
override func didAppear(cell: LoadingSpinnerCell) {
|
||||
cell.loadingSpinner.startAnimating()
|
||||
}
|
||||
|
||||
override func willDisappear(cell: LoadingSpinnerCell?) {
|
||||
cell?.loadingSpinner.stopAnimating()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import Combine
|
||||
import UIKit
|
||||
|
||||
struct ItemModel {
|
||||
|
||||
enum Item {
|
||||
case text(String)
|
||||
case textWithImage(String, UIImage)
|
||||
}
|
||||
|
||||
var id: String
|
||||
var item: Item
|
||||
}
|
||||
|
||||
struct DataModel {
|
||||
var isLoading: Bool
|
||||
var hasMore: Bool
|
||||
var items: [ItemModel]
|
||||
}
|
||||
|
||||
final class ModelProvider {
|
||||
|
||||
private var isLoading = false
|
||||
private var hasMore = true
|
||||
private var items: [ItemModel] = []
|
||||
|
||||
let dataModelPublisher: AnyPublisher<DataModel, Error>
|
||||
private let dataModelSubject: CurrentValueSubject<DataModel, Error>
|
||||
|
||||
init() {
|
||||
dataModelSubject = CurrentValueSubject(DataModel(isLoading: isLoading, hasMore: hasMore, items: items))
|
||||
dataModelPublisher = dataModelSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
guard !isLoading else {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
publishDataModel()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.isLoading = false
|
||||
for _ in 0..<3 {
|
||||
switch Int.random(in: 0...1) {
|
||||
case 0:
|
||||
self.items.append(ItemModel(id: "\(self.items.count)", item: .text("Text")))
|
||||
case 1:
|
||||
self.items.append(
|
||||
ItemModel(id: "\(self.items.count)", item: .textWithImage("Text with image", randomImage())))
|
||||
default:
|
||||
assertionFailure("Should not get here.")
|
||||
}
|
||||
|
||||
}
|
||||
self.publishDataModel()
|
||||
}
|
||||
}
|
||||
|
||||
private func publishDataModel() {
|
||||
dataModelSubject.send(DataModel(isLoading: isLoading, hasMore: hasMore, items: items))
|
||||
}
|
||||
}
|
||||
|
||||
private func randomImage() -> UIImage {
|
||||
let colors: [UIColor] = [.red, .blue, .cyan, .green, .purple]
|
||||
let color = colors.randomElement()!
|
||||
let size = CGSize(width: 40, height: 40)
|
||||
return UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
color.setFill()
|
||||
rendererContext.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
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).
|
||||
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||
let viewController = ViewController()
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = viewController
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
// Called as the scene is being released by the system.
|
||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the background to the foreground.
|
||||
// Use this method to undo the changes made on entering the background.
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import ListDiffUI
|
||||
import UIKit
|
||||
|
||||
struct TextViewModel: ListViewModel, Equatable {
|
||||
|
||||
var identifier: String
|
||||
var text: String
|
||||
}
|
||||
|
||||
final class TextCell: ListCell {
|
||||
|
||||
lazy var label: UILabel = {
|
||||
let label = UILabel()
|
||||
contentView.addSubview(label)
|
||||
label.frame = CGRect(x: 16, y: 0, width: contentView.bounds.width - 32, height: contentView.bounds.height)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .lightGray
|
||||
contentView.layer.masksToBounds = true
|
||||
contentView.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Unimplemented")
|
||||
}
|
||||
}
|
||||
|
||||
final class TextCellController: ListCellController<
|
||||
TextViewModel,
|
||||
ListViewStateNone,
|
||||
TextCell
|
||||
>
|
||||
{
|
||||
|
||||
override func itemSize(containerSize: CGSize) -> CGSize {
|
||||
return CGSize(width: containerSize.width, height: 40)
|
||||
}
|
||||
|
||||
override func configureCell(cell: TextCell) {
|
||||
cell.label.text = viewModel.text
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import ListDiffUI
|
||||
import UIKit
|
||||
|
||||
struct TextWithImageViewModel: ListViewModel, Equatable {
|
||||
|
||||
var identifier: String
|
||||
var text: String
|
||||
var image: UIImage
|
||||
}
|
||||
|
||||
final class TextWithImageCell: ListCell {
|
||||
|
||||
lazy var label: UILabel = {
|
||||
let label = UILabel()
|
||||
contentView.addSubview(label)
|
||||
label.frame = CGRect(x: 16, y: 80, width: contentView.bounds.width - 32, height: 40)
|
||||
return label
|
||||
}()
|
||||
|
||||
lazy var imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
contentView.addSubview(imageView)
|
||||
imageView.frame = CGRect(x: 0, y: 0, width: contentView.bounds.width, height: 80)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .lightGray
|
||||
contentView.layer.masksToBounds = true
|
||||
contentView.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Unimplemented")
|
||||
}
|
||||
}
|
||||
|
||||
final class TextWithImageCellController: ListCellController<
|
||||
TextWithImageViewModel,
|
||||
ListViewStateNone,
|
||||
TextWithImageCell
|
||||
>
|
||||
{
|
||||
|
||||
override func itemSize(containerSize: CGSize) -> CGSize {
|
||||
return CGSize(width: containerSize.width, height: 120)
|
||||
}
|
||||
|
||||
override func configureCell(cell: TextWithImageCell) {
|
||||
cell.label.text = viewModel.text
|
||||
cell.imageView.image = viewModel.image
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import Combine
|
||||
import ListDiffUI
|
||||
import UIKit
|
||||
|
||||
final class ViewController: UIViewController {
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let flowLayout = UICollectionViewFlowLayout()
|
||||
flowLayout.scrollDirection = .vertical
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
||||
collectionView.contentInset = .init(top: 0, left: 32, bottom: 0, right: 32)
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
private lazy var dataSource: ListDiffDataSource = {
|
||||
let dataSource = ListDiffDataSource(collectionView: collectionView)
|
||||
return dataSource
|
||||
}()
|
||||
|
||||
private let modelProvider = ModelProvider()
|
||||
private var anyCancellable: AnyCancellable?
|
||||
|
||||
override func viewDidLoad() {
|
||||
view.backgroundColor = .white
|
||||
|
||||
view.addSubview(collectionView)
|
||||
collectionView.frame = view.bounds
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
anyCancellable = modelProvider.dataModelPublisher.sink { _ in
|
||||
} receiveValue: { [weak self] dataModel in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.dataSource.setRootSection(self.buildSection(dataModel))
|
||||
}
|
||||
}
|
||||
|
||||
private func buildSection(_ dataModel: DataModel) -> Section {
|
||||
return CompositeSection(
|
||||
ListRenderSection(dataModel.items) {
|
||||
switch $0.item {
|
||||
case let .text(text):
|
||||
return ListSection<
|
||||
TextViewModel,
|
||||
TextCellController
|
||||
>(TextViewModel(identifier: $0.id, text: text))
|
||||
case let .textWithImage(text, image):
|
||||
return ListSection<
|
||||
TextWithImageViewModel,
|
||||
TextWithImageCellController
|
||||
>(TextWithImageViewModel(identifier: $0.id, text: text, image: image))
|
||||
}
|
||||
},
|
||||
dataModel.isLoading
|
||||
? ListSection<
|
||||
LoadingSpinnerViewModel,
|
||||
LoadingSpinnerCellController
|
||||
>(LoadingSpinnerViewModel(height: 40)) : nil,
|
||||
!dataModel.isLoading && dataModel.hasMore
|
||||
? ListSection<
|
||||
EndOfListViewModel,
|
||||
EndOfListCellController
|
||||
>(EndOfListViewModel(), delegate: .init(object: self)) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewController: EndOfListCellControllerDelegate {
|
||||
|
||||
func didTapLoadMore() {
|
||||
modelProvider.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemModel: Identifiable {
|
||||
|
||||
var identifier: String {
|
||||
id
|
||||
}
|
||||
}
|
|
@ -156,7 +156,7 @@ Assuming we are building a ToDo list, to build it with ListDiffUI framework:
|
|||
```swift
|
||||
let dataSource = ListDiffDataSource(collectionView: collectionView)
|
||||
```
|
||||
|
||||
|
||||
3. Observe data model updates, and set root section on the ListDiffDataSource
|
||||
```swift
|
||||
dataSource.setRootSection(
|
||||
|
@ -170,7 +170,7 @@ Assuming we are building a ToDo list, to build it with ListDiffUI framework:
|
|||
|
||||
And that's it, ListDiffUI framework will take care of building the root section into an array of view models and updating UI accordingly.
|
||||
|
||||
Refer to [the sample app](https://github.com/siyuyue/ListDiffUI/tree/main/Examples/SampleApp) for a slightly more complicated example, that also showcases a few additional features in the ListDiffUI framework, including:
|
||||
Refer to [the sample apps](https://github.com/siyuyue/ListDiffUI/tree/main/Examples) for some examples, that showcases a few additional features in the ListDiffUI framework, including:
|
||||
- Heterogeneous cells
|
||||
- Asynchronous diffing on background thread (This is a configuration on ListDiffDataSource)
|
||||
- Passing in delegate objects to each controller to handle data mutation
|
||||
|
|
Loading…
Reference in New Issue