Add SampleApp2 (#3)

This commit is contained in:
siyuyue 2023-01-29 10:09:07 -08:00 committed by GitHub
parent 9c84e36de7
commit c8cdb88be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 517 additions and 6 deletions

View File

@ -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"]),
],
)

0
Examples/README.md Normal file
View File

BIN
Examples/SampleApp.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

View File

@ -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<

BIN
Examples/SampleApp2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

View File

@ -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.
}
}

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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()
}
}

View File

@ -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))
}
}

View File

@ -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.
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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