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"]), 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 button.backgroundColor = .blue
return button return button
}() }()
func configure(viewModel: ButtonViewModel, viewState: ListViewStateNone) {
button.setTitle(viewModel.title, for: .normal)
}
} }
final class ButtonCellController: ListCellController< 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

@ -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. 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 - Heterogeneous cells
- Asynchronous diffing on background thread (This is a configuration on ListDiffDataSource) - Asynchronous diffing on background thread (This is a configuration on ListDiffDataSource)
- Passing in delegate objects to each controller to handle data mutation - Passing in delegate objects to each controller to handle data mutation