├── .gitignore ├── .travis.yml ├── DataSourcerer.podspec ├── DataSourcerer ├── Assets │ └── .gitkeep └── Classes │ ├── .gitkeep │ ├── Core │ ├── BroadcastObservable.swift │ ├── Datasource+Builder.swift │ ├── Datasource.swift │ ├── Disposable.swift │ ├── EquatableBox.swift │ ├── LoadImpulse.swift │ ├── LoadImpulseEmitter.swift │ ├── Observable+Cache.swift │ ├── Observable+LoadingEnded.swift │ ├── Observable+RememberLatestSuccessAndError.swift │ ├── Observable+Transformations.swift │ ├── Observable.swift │ ├── Property.swift │ ├── ResourceParams.swift │ ├── ResourceState.swift │ ├── ResourceStatePersister.swift │ ├── ShareableValueStream.swift │ ├── SourcererExtensionsProvider.swift │ ├── SynchronizedExecuter.swift │ ├── ValueStream+URLSession.swift │ └── ValueStream.swift │ ├── List-UIKit │ ├── CollectionReusableViewProducer.swift │ ├── CollectionViewCellProducer.swift │ ├── IdiomaticErrorTableViewCell.swift │ ├── IdiomaticLoadingTableViewCell.swift │ ├── ListSections+Dwifft.swift │ ├── SimpleCollectionViewDatasource.swift │ ├── SingleSectionTableViewController.swift │ ├── TableHeaderViewProducer.swift │ ├── TableViewCellProducer.swift │ ├── TableViewDatasource.swift │ └── UIRefreshControl+EndRefreshing.swift │ ├── List │ ├── ItemModel.swift │ ├── ItemModelsProducer.swift │ ├── ItemViewProducer.swift │ ├── ItemViewsProducer.swift │ ├── ListViewDatasourceConfiguration+Builder.swift │ ├── ListViewDatasourceConfiguration.swift │ ├── SectionModel.swift │ ├── ShowLoadingAndErrors.swift │ ├── SupplementaryItemModel.swift │ └── SupplementaryItemModelProducer.swift │ ├── Persister-Cache │ └── CachePersister.swift │ └── ReactiveSwift │ └── ReactiveSwift.swift ├── Example ├── .swiftlint.yml ├── BACKLOG.md ├── DataSourcerer.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── DataSourcerer-Example.xcscheme ├── DataSourcerer.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── DataSourcerer │ ├── APIError.swift │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── LaunchScreen.xib │ │ └── Main.storyboard │ ├── ChatBotCell.swift │ ├── ChatBotIncomingMessageTableViewCell.swift │ ├── ChatBotItemViewsProducer.swift │ ├── ChatBotMockStorage.swift │ ├── ChatBotRequest.swift │ ├── ChatBotResponse.swift │ ├── ChatBotTableCellUpdateInterceptor.swift │ ├── ChatBotTableItemModelsProducer.swift │ ├── ChatBotTableViewController.swift │ ├── ChatBotTableViewModel.swift │ ├── ErrorTableViewCell.swift │ ├── Images.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── chat_bubble_incoming.imageset │ │ │ ├── Contents.json │ │ │ ├── chat_bubble_incoming.png │ │ │ ├── chat_bubble_incoming@2x.png │ │ │ └── chat_bubble_incoming@3x.png │ ├── Info.plist │ ├── InitialChatBotStates.swift │ ├── LoadingCell.swift │ ├── NewMessagesChatBotStates.swift │ ├── OldMessagesChatBotStates.swift │ ├── PublicRepo.swift │ ├── PullToRefreshTableViewController.swift │ ├── PullToRefreshTableViewModel.swift │ └── Watchdog.swift ├── Podfile ├── Podfile.lock └── Tests │ ├── .swiftlint.yml │ ├── BroadcastObservableSpec.swift │ ├── CachedDatasourceSpec.swift │ ├── DatasourceOperationsSpec.swift │ ├── Info.plist │ ├── LoadImpulseEmitterSpec.swift │ ├── PlainCacheDatasourceSpec.swift │ ├── TestDatasource.swift │ ├── TestStateError.swift │ └── TestStatePersister.swift ├── LICENSE ├── README.md ├── _Pods.xcodeproj └── logo.svg /.gitignore: -------------------------------------------------------------------------------- 1 | Example/Pods/**/* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # references: 2 | # * https://www.objc.io/issues/6-build-tools/travis-ci/ 3 | # * https://github.com/supermarin/xcpretty#usage 4 | 5 | osx_image: xcode10.1 6 | language: objective-c 7 | # cache: cocoapods 8 | # podfile: Example/Podfile 9 | # before_install: 10 | # - gem install cocoapods # Since Travis is not always on latest version 11 | # - pod install --project-directory=Example 12 | script: 13 | - set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/DataSourcerer.xcworkspace -scheme DataSourcerer-Example -sdk iphonesimulator12.1 ONLY_ACTIVE_ARCH=NO | xcpretty 14 | - pod lib lint 15 | -------------------------------------------------------------------------------- /DataSourcerer.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint DataSourcerer.podspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'DataSourcerer' 11 | s.version = '0.2.7' 12 | s.summary = 'The missing link between API Calls (any data provider actually) and your UITableView (any view actually).' 13 | 14 | # This description is used to generate tags and improve search results. 15 | # * Think: What does it do? Why did you write it? What is the focus? 16 | # * Try to keep it short, snappy and to the point. 17 | # * Write the description between the DESC delimiters below. 18 | # * Finally, don't worry about the indent, CocoaPods strips it! 19 | 20 | s.description = <<-DESC 21 | The missing link between API Calls (any data provider actually) and your UITableView (any view actually). 22 | DESC 23 | 24 | s.homepage = 'https://github.com/creativepragmatics/DataSourcerer' 25 | s.license = { :type => 'MIT', :file => 'LICENSE' } 26 | s.author = { 'Manuel Maly @ Creative Pragmatics' => 'manuel@creativepragmatics.com' } 27 | s.source = { :git => 'https://github.com/creativepragmatics/DataSourcerer.git', :tag => s.version.to_s } 28 | s.social_media_url = 'https://twitter.com/manuelmaly' 29 | 30 | s.ios.deployment_target = '9.0' 31 | 32 | s.source_files = 'DataSourcerer/Classes/**/*' 33 | s.swift_version = '4.2' 34 | 35 | s.subspec 'Core' do |ss| 36 | ss.source_files = 'DataSourcerer/Classes/Core/**/*' 37 | end 38 | 39 | s.subspec 'List' do |ss| 40 | ss.source_files = 'DataSourcerer/Classes/List/**/*' 41 | ss.dependency 'DataSourcerer/Core' 42 | end 43 | 44 | s.subspec 'List-UIKit' do |ss| 45 | ss.source_files = 'DataSourcerer/Classes/List-UIKit/**/*' 46 | ss.dependency 'DataSourcerer/List' 47 | ss.dependency 'Dwifft', '~> 0.9' 48 | end 49 | 50 | s.subspec 'Persister-Cache' do |ss| 51 | ss.source_files = 'DataSourcerer/Classes/Persister-Cache/**/*' 52 | ss.dependency 'DataSourcerer/Core' 53 | ss.dependency 'Cache', '~> 5.2.0' 54 | end 55 | 56 | s.subspec 'ReactiveSwift' do |ss| 57 | ss.source_files = 'DataSourcerer/Classes/ReactiveSwift/**/*' 58 | ss.dependency 'DataSourcerer/List' 59 | ss.dependency 'ReactiveSwift', '~> 4.0' 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /DataSourcerer/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativepragmatics/DataSourcerer/eee12402b3d14f9da9f63bb17f09feee9fc11f9d/DataSourcerer/Assets/.gitkeep -------------------------------------------------------------------------------- /DataSourcerer/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativepragmatics/DataSourcerer/eee12402b3d14f9da9f63bb17f09feee9fc11f9d/DataSourcerer/Classes/.gitkeep -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/BroadcastObservable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Broadcasts values to one or more observers. This observable 4 | /// is primarily used as a helper for ShareableValueStream. 5 | public final class BroadcastObservable: ObservableProtocol { 6 | public typealias ObservedValue = ObservedValue_ 7 | 8 | private let observers = SynchronizedMutableProperty([Int: ValuesOverTime]()) 9 | 10 | public init() {} 11 | 12 | public func observe(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable { 13 | 14 | let uniqueKey = Int(arc4random_uniform(10_000)) 15 | observers.modify { 16 | $0[uniqueKey] = valuesOverTime 17 | } 18 | 19 | let disposable = ActionDisposable { 20 | self.observers.modify { $0.removeValue(forKey: uniqueKey) } 21 | } 22 | 23 | return CompositeDisposable(disposable, objectToRetain: self) 24 | } 25 | 26 | public func emit(_ value: ObservedValue) { 27 | 28 | observers.value.values.forEach { valuesOverTime in 29 | valuesOverTime(value) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Datasource+Builder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Datasource { 4 | 5 | struct Builder { 6 | 7 | public struct ResourceStateAndLoadImpulseEmitterSelected { 8 | let resourceState: AnyObservable> 9 | let loadImpulseEmitter: AnyLoadImpulseEmitter

10 | 11 | public func cacheBehavior(_ cacheBehavior: CacheBehavior) -> CacheBehaviorSelected { 12 | return CacheBehaviorSelected(previous: self, 13 | cacheBehavior: cacheBehavior) 14 | } 15 | } 16 | 17 | public struct CacheBehaviorSelected { 18 | let previous: ResourceStateAndLoadImpulseEmitterSelected 19 | let cacheBehavior: CacheBehavior 20 | 21 | public var datasource: Datasource { 22 | 23 | let cachedStates = cacheBehavior 24 | .apply(on: previous.resourceState, 25 | loadImpulseEmitter: previous.loadImpulseEmitter) 26 | .skipRepeats() 27 | .observeOnUIThread() 28 | 29 | let shareableCachedState = cachedStates 30 | .shareable(initialValue: ResourceState.notReady) 31 | 32 | return Datasource( 33 | shareableCachedState, 34 | loadImpulseEmitter: previous.loadImpulseEmitter 35 | ) 36 | } 37 | 38 | } 39 | 40 | public struct ValueStreamSelected { 41 | let states: ValueStream 42 | } 43 | } 44 | 45 | } 46 | 47 | public extension Datasource.Builder where Value: Codable { 48 | 49 | struct LoadFromURLRequestSelected { 50 | let urlRequest: (LoadImpulse

) throws -> URLRequest 51 | 52 | public func setRememberLatestSuccessAndErrorBehavior(_ behavior: RememberLatestSuccessAndErrorBehavior) 53 | -> RememberLatestSuccessAndErrorBehaviorSelected { 54 | 55 | return RememberLatestSuccessAndErrorBehaviorSelected( 56 | urlRequestSelected: self, 57 | rememberLatestSuccessAndErrorBehavior: behavior 58 | ) 59 | } 60 | 61 | } 62 | 63 | struct RememberLatestSuccessAndErrorBehaviorSelected { 64 | let urlRequestSelected: LoadFromURLRequestSelected 65 | let rememberLatestSuccessAndErrorBehavior: RememberLatestSuccessAndErrorBehavior 66 | 67 | public func mapErrorToString(_ mapErrorString: @escaping (Datasource.ErrorString) -> E) 68 | -> LoadFromURLErrorMappingSelected { 69 | return LoadFromURLErrorMappingSelected( 70 | rememberLatestSuccessAndErrorBehaviorSelected: self, 71 | mapErrorString: mapErrorString 72 | ) 73 | } 74 | } 75 | 76 | struct LoadFromURLErrorMappingSelected { 77 | let rememberLatestSuccessAndErrorBehaviorSelected: RememberLatestSuccessAndErrorBehaviorSelected 78 | let mapErrorString: (Datasource.ErrorString) -> E 79 | 80 | public func loadImpulseBehavior(_ loadImpulseBehavior: Datasource.LoadImpulseBehavior) 81 | -> ResourceStateAndLoadImpulseEmitterSelected { 82 | 83 | let resourceState = ValueStream( 84 | loadStatesWithURLRequest: rememberLatestSuccessAndErrorBehaviorSelected 85 | .urlRequestSelected.urlRequest, 86 | mapErrorString: mapErrorString, 87 | loadImpulseEmitter: loadImpulseBehavior.loadImpulseEmitter 88 | ) 89 | .rememberLatestSuccessAndError( 90 | behavior: rememberLatestSuccessAndErrorBehaviorSelected 91 | .rememberLatestSuccessAndErrorBehavior 92 | ) 93 | 94 | return ResourceStateAndLoadImpulseEmitterSelected( 95 | resourceState: resourceState, 96 | loadImpulseEmitter: loadImpulseBehavior.loadImpulseEmitter 97 | ) 98 | } 99 | 100 | } 101 | 102 | } 103 | 104 | public extension Datasource where Value: Codable { 105 | 106 | static func loadFromURL( 107 | urlRequest: @escaping (LoadImpulse

) throws -> URLRequest, 108 | withParameterType: P.Type, 109 | expectResponseValueType: Value.Type, 110 | failWithError: E.Type 111 | ) -> Builder.LoadFromURLRequestSelected { 112 | return Builder.LoadFromURLRequestSelected(urlRequest: urlRequest) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Datasource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Provides the data for a sectioned or unsectioned list. Can be reused 4 | /// by multiple views displaying its data. 5 | public struct Datasource { 6 | public typealias ObservedState = ResourceState 7 | public typealias ErrorString = String 8 | 9 | public let state: ShareableValueStream 10 | public let loadImpulseEmitter: AnyLoadImpulseEmitter

11 | 12 | public init(_ state: ShareableValueStream, 13 | loadImpulseEmitter: AnyLoadImpulseEmitter

) { 14 | self.state = state 15 | self.loadImpulseEmitter = loadImpulseEmitter 16 | } 17 | } 18 | 19 | public extension Datasource { 20 | 21 | func refresh( 22 | params: P, 23 | type: LoadImpulseType, 24 | on queue: LoadImpulseEmitterQueue = .distinct(DispatchQueue(label: "PublicReposViewModel.refresh")) 25 | ) { 26 | let loadImpulse = LoadImpulse(params: params, type: type) 27 | loadImpulseEmitter.emit(loadImpulse: loadImpulse, on: queue) 28 | } 29 | } 30 | 31 | public extension Datasource { 32 | 33 | init( 34 | combine datasources: S, 35 | map: @escaping ([ObservedState]) -> ObservedState 36 | ) where S.Element == Datasource { 37 | 38 | let observables = datasources 39 | .map { $0.state.any } 40 | 41 | state = AnyObservable 42 | .combine(observables: observables, map: map) 43 | .shareable(initialValue: .notReady) 44 | 45 | // Insert nonfunctional load impulse emitter - values will only flow from the 46 | // sub datasources. 47 | self.loadImpulseEmitter = SimpleLoadImpulseEmitter(initialImpulse: nil).any 48 | } 49 | 50 | init( 51 | combine first: Datasource, 52 | with second: Datasource, 53 | map: @escaping (ObservedState, ObservedState) -> ObservedState 54 | ) { 55 | 56 | self.init(combine: [first, second]) { states 57 | -> ResourceState in 58 | map(states[0], states[1]) 59 | } 60 | } 61 | 62 | init( 63 | combine first: Datasource, 64 | with second: Datasource, 65 | and third: Datasource, 66 | map: @escaping (ObservedState, ObservedState, ObservedState) -> ObservedState 67 | ) { 68 | 69 | self.init(combine: [first, second, third]) { states 70 | -> ResourceState in 71 | map(states[0], states[1], states[2]) 72 | } 73 | } 74 | 75 | } 76 | 77 | public extension Datasource { 78 | 79 | func combine( 80 | with other: Datasource, 81 | map: @escaping (ObservedState, ResourceState) 82 | -> ResourceState 83 | ) -> Datasource { 84 | 85 | let combinedState = self.state.any 86 | .combine(with: other.state.any) 87 | .map(map) 88 | .shareable(initialValue: .notReady) 89 | 90 | // Insert nonfunctional load impulse emitter - values will only flow from the 91 | // sub datasources. 92 | let loadImpulseEmitter = SimpleLoadImpulseEmitter

(initialImpulse: nil).any 93 | 94 | return Datasource(combinedState, loadImpulseEmitter: loadImpulseEmitter) 95 | } 96 | 97 | } 98 | 99 | public extension Datasource where P == NoResourceParams { 100 | 101 | func refresh( 102 | type: LoadImpulseType, 103 | on queue: LoadImpulseEmitterQueue = .distinct(DispatchQueue(label: "PublicReposViewModel.refresh")) 104 | ) { 105 | refresh(params: NoResourceParams(), type: type, on: queue) 106 | } 107 | } 108 | 109 | public extension Datasource { 110 | 111 | enum CacheBehavior { 112 | case none 113 | case persist(persister: AnyResourceStatePersister, cacheLoadError: E) 114 | 115 | public func apply(on observable: AnyObservable>, 116 | loadImpulseEmitter: AnyLoadImpulseEmitter

) 117 | -> AnyObservable> { 118 | switch self { 119 | case .none: 120 | return observable 121 | case let .persist(persister, cacheLoadError): 122 | return observable.persistedCachedState( 123 | persister: persister, 124 | loadImpulseEmitter: loadImpulseEmitter, 125 | cacheLoadError: cacheLoadError 126 | ) 127 | } 128 | } 129 | } 130 | } 131 | 132 | public extension Datasource { 133 | 134 | enum LoadImpulseBehavior { 135 | case `default`(initialParameters: P?) 136 | case recurring( 137 | initialParameters: P?, 138 | timerMode: RecurringLoadImpulseEmitter

.TimerMode, 139 | timerEmitQueue: DispatchQueue? 140 | ) 141 | case instance(AnyLoadImpulseEmitter

) 142 | 143 | var loadImpulseEmitter: AnyLoadImpulseEmitter

{ 144 | switch self { 145 | case let .default(initialParameters): 146 | let initialImpulse = initialParameters.map { LoadImpulse

(params: $0, type: .initial) } 147 | return SimpleLoadImpulseEmitter(initialImpulse: initialImpulse).any 148 | case let .instance(loadImpulseEmitter): 149 | return loadImpulseEmitter 150 | case let .recurring(initialParameters, 151 | timerMode, 152 | timerEmitQueue): 153 | let initialImpulse = initialParameters.map { LoadImpulse

(params: $0, type: .initial) } 154 | return RecurringLoadImpulseEmitter(initialImpulse: initialImpulse, 155 | timerMode: timerMode, 156 | timerEmitQueue: timerEmitQueue).any 157 | } 158 | } 159 | } 160 | 161 | } 162 | 163 | //public extension Datasource where Value: Codable { 164 | // 165 | // init( 166 | // urlRequest: @escaping (LoadImpulse

) throws -> URLRequest, 167 | // mapErrorString: @escaping (ErrorString) -> E, 168 | // cacheBehavior: CacheBehavior, 169 | // loadImpulseBehavior: LoadImpulseBehavior 170 | // ) { 171 | // 172 | // let loadImpulseEmitter = loadImpulseBehavior.loadImpulseEmitter 173 | // 174 | // let states = ValueStream( 175 | // loadStatesWithURLRequest: urlRequest, 176 | // mapErrorString: mapErrorString, 177 | // loadImpulseEmitter: loadImpulseEmitter 178 | // ) 179 | // .retainLastResultState() 180 | // 181 | // let cachedStates = cacheBehavior 182 | // .apply(on: states.any, 183 | // loadImpulseEmitter: loadImpulseEmitter) 184 | // .skipRepeats() 185 | // .observeOnUIThread() 186 | // 187 | // let shareableCachedStates = cachedStates 188 | // .shareable(initialValue: ResourceState.notReady) 189 | // 190 | // self.init(shareableCachedStates, loadImpulseEmitter: loadImpulseEmitter) 191 | // } 192 | //} 193 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Disposable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Disposable: AnyObject { 4 | func dispose() 5 | 6 | var isDisposed: Bool { get } 7 | } 8 | 9 | public extension Disposable { 10 | func disposed(by bag: DisposeBag) { 11 | bag.add(self) 12 | } 13 | } 14 | 15 | public final class InstanceRetainingDisposable: Disposable { 16 | 17 | public var isDisposed: Bool { 18 | return _isDisposed.value 19 | } 20 | 21 | private var instance: AnyObject? 22 | private let _isDisposed = SynchronizedMutableProperty(false) 23 | 24 | init(_ instance: AnyObject) { 25 | self.instance = instance 26 | } 27 | 28 | public func dispose() { 29 | guard _isDisposed.set(true, ifCurrentValueIs: false) else { return } 30 | instance = nil // remove retain on instance 31 | } 32 | 33 | } 34 | 35 | public final class CompositeDisposable: Disposable { 36 | 37 | private let disposables = SynchronizedMutableProperty<[Disposable]>([]) 38 | 39 | public var isDisposed: Bool { 40 | if disposables.value.contains(where: { $0.isDisposed == false }) { 41 | return false 42 | } else { 43 | return true 44 | } 45 | } 46 | 47 | init() {} 48 | 49 | init(_ disposables: [Disposable]) { 50 | self.disposables.value = disposables 51 | } 52 | 53 | public func add(_ disposable: Disposable) { 54 | disposables.modify({ $0 += [disposable] }) 55 | } 56 | 57 | public func dispose() { 58 | disposables.value.forEach({ $0.dispose() }) 59 | disposables.value = [] 60 | } 61 | 62 | public static func += (lhs: CompositeDisposable, rhs: Disposable) { 63 | lhs.add(rhs) 64 | } 65 | 66 | } 67 | 68 | public final class VoidDisposable: Disposable { 69 | 70 | public var isDisposed: Bool { 71 | return _isDisposed.value 72 | } 73 | 74 | private let _isDisposed = SynchronizedMutableProperty(false) 75 | 76 | public init() {} 77 | public func dispose() { 78 | _ = _isDisposed.set(true, ifCurrentValueIs: false) 79 | } 80 | 81 | } 82 | 83 | public extension CompositeDisposable { 84 | 85 | convenience init(_ disposableA: Disposable, objectToRetain: AnyObject) { 86 | self.init([disposableA, InstanceRetainingDisposable(objectToRetain)]) 87 | } 88 | 89 | convenience init(_ disposables: [Disposable], objectToRetain: AnyObject) { 90 | self.init(disposables + [InstanceRetainingDisposable(objectToRetain)]) 91 | } 92 | } 93 | 94 | public final class ActionDisposable: Disposable { 95 | 96 | public var isDisposed: Bool { 97 | return _isDisposed.value 98 | } 99 | 100 | private var action: (() -> Void)? 101 | private let _isDisposed = SynchronizedMutableProperty(false) 102 | 103 | public init(_ action: @escaping () -> Void) { 104 | self.action = action 105 | } 106 | 107 | public func dispose() { 108 | guard _isDisposed.set(true, ifCurrentValueIs: false) else { return } 109 | 110 | action?() 111 | // Release just in case the instance is kept alive: 112 | action = nil 113 | } 114 | } 115 | 116 | public final class DisposeBag { 117 | 118 | private let disposable = CompositeDisposable() 119 | 120 | public init() {} 121 | 122 | public func add(_ disposable: Disposable) { 123 | self.disposable.add(disposable) 124 | } 125 | 126 | public func dispose() { 127 | disposable.dispose() 128 | } 129 | 130 | deinit { 131 | dispose() 132 | } 133 | } 134 | 135 | public final class SimpleDisposable: Disposable { 136 | 137 | public var isDisposed: Bool { 138 | return _isDisposed.value 139 | } 140 | 141 | private let _isDisposed = SynchronizedMutableProperty(false) 142 | 143 | public func dispose() { 144 | guard _isDisposed.set(true, ifCurrentValueIs: false) else { return } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/EquatableBox.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Value is wrapped in a box such that equality checks can be 4 | /// done with less overhead. The premise is that no two boxes 5 | /// are the same due to the usage of UUIDs. Thus if two boxes 6 | /// are the same, it can be deduced that their values must also be equal. 7 | /// 8 | /// The ideal scenario is that a box is only instantiated when an 9 | /// API or Cache response is generated, or if a value is read from 10 | /// disk cache. After that, the box is just passed around (and equated a lot) 11 | /// until the value is finally used. 12 | public struct EquatableBox: Equatable { 13 | public let value: Value 14 | let equalityId: String 15 | 16 | public init(_ value: Value) { 17 | self.value = value 18 | self.equalityId = UUID().uuidString 19 | } 20 | 21 | public static func == (lhs: EquatableBox, rhs: EquatableBox) -> Bool { 22 | return lhs.equalityId == rhs.equalityId 23 | } 24 | } 25 | 26 | extension EquatableBox: Codable where Value : Codable {} 27 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/LoadImpulse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LoadImpulse: Equatable { 4 | 5 | public var params: P 6 | public let type: LoadImpulseType 7 | 8 | public init( 9 | params: P, 10 | type: LoadImpulseType 11 | ) { 12 | self.params = params 13 | self.type = type 14 | } 15 | 16 | public func with(params: P) -> LoadImpulse

{ 17 | var modified = self 18 | modified.params = params 19 | return modified 20 | } 21 | 22 | func isCacheCompatible(_ candidate: LoadImpulse

) -> Bool { 23 | return params.isCacheCompatible(candidate.params) 24 | } 25 | } 26 | 27 | public struct LoadImpulseType: Codable, Equatable { 28 | 29 | public let mode: Mode 30 | public let issuer: Issuer 31 | public let showLoadingIndicator: Bool 32 | 33 | public init(mode: Mode, issuer: Issuer) { 34 | switch issuer { 35 | case .system: 36 | self.init(mode: mode, issuer: issuer, showLoadingIndicator: true) 37 | case .user: 38 | // For pull-to-refresh, we don't want a 39 | // second loading indicator beneath the 40 | // refresh control. 41 | self.init(mode: mode, issuer: issuer, showLoadingIndicator: false) 42 | } 43 | } 44 | 45 | public init(mode: Mode, issuer: Issuer, showLoadingIndicator: Bool) { 46 | self.mode = mode 47 | self.issuer = issuer 48 | self.showLoadingIndicator = showLoadingIndicator 49 | } 50 | 51 | public enum Mode: String, Codable, Equatable { 52 | case initial 53 | case fullRefresh 54 | case partialLoad 55 | case partialReload 56 | } 57 | 58 | public enum Issuer: String, Codable, Equatable { 59 | case user 60 | case system 61 | } 62 | 63 | public static let initial = LoadImpulseType(mode: .initial, issuer: .system) 64 | } 65 | 66 | extension LoadImpulse : Codable where P: Codable {} 67 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/LoadImpulseEmitter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol LoadImpulseEmitterProtocol: ObservableProtocol where ObservedValue == LoadImpulse

{ 4 | associatedtype P: ResourceParams 5 | typealias LoadImpulsesOverTime = ValuesOverTime 6 | 7 | func emit(loadImpulse: LoadImpulse

, on queue: LoadImpulseEmitterQueue) 8 | } 9 | 10 | public extension LoadImpulseEmitterProtocol where P == NoResourceParams { 11 | 12 | func emit(type: LoadImpulseType, on queue: LoadImpulseEmitterQueue) { 13 | let loadImpulse = LoadImpulse(params: NoResourceParams(), type: type) 14 | emit(loadImpulse: loadImpulse, on: queue) 15 | } 16 | } 17 | 18 | public enum LoadImpulseEmitterQueue { 19 | case current 20 | case distinct(DispatchQueue) 21 | } 22 | 23 | public extension LoadImpulseEmitterProtocol { 24 | var any: AnyLoadImpulseEmitter

{ 25 | return AnyLoadImpulseEmitter(self) 26 | } 27 | } 28 | 29 | public struct AnyLoadImpulseEmitter: LoadImpulseEmitterProtocol { 30 | public typealias P = P_ 31 | public typealias ObservedValue = LoadImpulse

32 | 33 | private let _observe: (@escaping LoadImpulsesOverTime) -> Disposable 34 | private let _emit: (LoadImpulse

, LoadImpulseEmitterQueue) -> Void 35 | 36 | init(_ emitter: Emitter) where Emitter.P == P { 37 | self._emit = emitter.emit 38 | self._observe = emitter.observe 39 | } 40 | 41 | public func emit(loadImpulse: LoadImpulse, on queue: LoadImpulseEmitterQueue) { 42 | _emit(loadImpulse, queue) 43 | } 44 | 45 | public func observe(_ loadImpulsesOverTime: @escaping LoadImpulsesOverTime) -> Disposable { 46 | return _observe(loadImpulsesOverTime) 47 | } 48 | } 49 | 50 | public class SimpleLoadImpulseEmitter: LoadImpulseEmitterProtocol, ObservableProtocol { 51 | public typealias P = P_ 52 | public typealias LI = LoadImpulse

53 | public typealias ObservedValue = LoadImpulse

54 | 55 | private let initialImpulse: LoadImpulse

? 56 | private let broadcastObservable = BroadcastObservable

  • () 57 | 58 | public init(initialImpulse: LoadImpulse

    ?) { 59 | self.initialImpulse = initialImpulse 60 | } 61 | 62 | public func observe(_ observe: @escaping LoadImpulsesOverTime) -> Disposable { 63 | 64 | if let initialImpulse = initialImpulse { 65 | observe(initialImpulse) 66 | } 67 | 68 | return broadcastObservable.observe(observe) 69 | } 70 | 71 | public func emit(loadImpulse: LoadImpulse

    , on queue: LoadImpulseEmitterQueue) { 72 | 73 | switch queue { 74 | case .current: 75 | broadcastObservable.emit(loadImpulse) 76 | case let .distinct(queue): 77 | queue.async { 78 | self.broadcastObservable.emit(loadImpulse) 79 | } 80 | } 81 | } 82 | 83 | } 84 | 85 | public class RecurringLoadImpulseEmitter: LoadImpulseEmitterProtocol, ObservableProtocol { 86 | public typealias P = P_ 87 | public typealias LI = LoadImpulse

    88 | public typealias ObservedValue = LoadImpulse

    89 | 90 | private let lastLoadImpulse: LoadImpulse

    ? 91 | private let innerEmitter: SimpleLoadImpulseEmitter

    92 | private let disposeBag = DisposeBag() 93 | private var timer = SynchronizedMutableProperty(nil) 94 | private var isObserved = SynchronizedMutableProperty(false) 95 | private let timerExecuter = SynchronizedExecuter() 96 | private let timerEmitQueue: DispatchQueue 97 | 98 | // TODO: refactor to use SynchronizedMutableProperty 99 | public var timerMode: TimerMode { 100 | didSet { 101 | resetTimer() 102 | } 103 | } 104 | 105 | public init(initialImpulse: LoadImpulse

    ?, 106 | timerMode: TimerMode = .none, 107 | timerEmitQueue: DispatchQueue? = nil) { 108 | 109 | self.lastLoadImpulse = initialImpulse 110 | self.timerMode = timerMode 111 | self.innerEmitter = SimpleLoadImpulseEmitter

    (initialImpulse: initialImpulse) 112 | self.timerEmitQueue = timerEmitQueue ?? 113 | DispatchQueue(label: "datasourcerer.recurringloadimpulseemitter.timer", attributes: []) 114 | } 115 | 116 | private func resetTimer() { 117 | 118 | timer.modify { [weak self] timer in 119 | timer?.cancel() 120 | guard let self = self else { return } 121 | 122 | switch self.timerMode { 123 | case .none: 124 | break 125 | case let .timeInterval(timeInterval): 126 | let newTimer = DispatchSource.makeTimerSource(queue: self.timerEmitQueue) 127 | newTimer.schedule(deadline: .now() + timeInterval, 128 | repeating: timeInterval, 129 | leeway: .milliseconds(100)) 130 | newTimer.setEventHandler { [weak self] in 131 | guard let lastLoadImpulse = self?.lastLoadImpulse else { return } 132 | self?.innerEmitter.emit(loadImpulse: lastLoadImpulse, on: .current) 133 | } 134 | newTimer.resume() 135 | timer = newTimer 136 | } 137 | } 138 | } 139 | 140 | public func observe(_ observe: @escaping LoadImpulsesOverTime) -> Disposable { 141 | 142 | defer { 143 | if isObserved.set(true, ifCurrentValueIs: false) { 144 | resetTimer() 145 | } 146 | } 147 | 148 | let innerDisposable = innerEmitter.observe(observe) 149 | return CompositeDisposable(innerDisposable, objectToRetain: self) 150 | } 151 | 152 | public func emit(loadImpulse: LoadImpulse

    , on queue: LoadImpulseEmitterQueue) { 153 | innerEmitter.emit(loadImpulse: loadImpulse, on: queue) 154 | resetTimer() 155 | } 156 | 157 | deinit { 158 | disposeBag.dispose() 159 | } 160 | 161 | public enum TimerMode { 162 | case none 163 | case timeInterval(DispatchTimeInterval) 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Observable+LoadingEnded.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension ObservableProtocol { 4 | 5 | typealias LoadingEnded = Void 6 | 7 | /// Returns AnyObservable because it does not necessarily, 8 | /// like Datasources, send an initial value synchronously 9 | /// when observe(_) is called. 10 | func loadingEnded() 11 | -> AnyObservable where ObservedValue == ResourceState { 12 | 13 | return self 14 | .filter { state in 15 | switch state.provisioningState { 16 | case .result: 17 | return true 18 | case .notReady, .loading: 19 | return false 20 | } 21 | } 22 | .map { _ in LoadingEnded() } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Observable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Base observable type. 4 | /// Each new subscription *might* prompt new work to be done. 5 | /// E.g. an observable that performs a network request might 6 | /// perform the request twice if it's observed twice. 7 | /// If you absolutely need shared state (e.g. each observation gets 8 | /// just the latest network request's result), consider using 9 | /// `ShareableValueStream`. 10 | public protocol ObservableProtocol { 11 | associatedtype ObservedValue 12 | typealias ValuesOverTime = (ObservedValue) -> Void 13 | 14 | func observe(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable 15 | } 16 | 17 | public extension ObservableProtocol { 18 | var any: AnyObservable { 19 | return AnyObservable(self) 20 | } 21 | } 22 | 23 | public struct AnyObservable: ObservableProtocol { 24 | public typealias ObservedValue = ObservedValue_ 25 | 26 | private let _observe: (@escaping ValuesOverTime) -> Disposable 27 | 28 | public init(_ observable: O) where O.ObservedValue == ObservedValue { 29 | self._observe = observable.observe 30 | } 31 | 32 | public func observe(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable { 33 | return _observe(valuesOverTime) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/Property.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// This protocol should only be used for conformance. 4 | internal protocol Property { 5 | associatedtype T 6 | 7 | var value: T { get } 8 | } 9 | 10 | public typealias PropertyDidSet = Bool 11 | 12 | /// This protocol should only be used for conformance. 13 | internal protocol MutableProperty: Property { 14 | associatedtype T 15 | 16 | var value: T { get set } 17 | 18 | func modify(_ mutate: @escaping (inout T) -> Void) 19 | func set(_ newValue: T, if condition: (T) -> Bool) -> PropertyDidSet 20 | } 21 | 22 | /// Thread-safe value wrapper with asynchronous setter and 23 | /// synchronous getter. 24 | /// 25 | /// Some discussion: https://twitter.com/manuelmaly/status/1077885584939630593?s=20 26 | public final class SynchronizedMutableProperty: MutableProperty { 27 | public typealias T = T_ 28 | 29 | public let executer: SynchronizedExecuter 30 | private var _value: T 31 | public var value: T { 32 | get { 33 | var currentValue: T? 34 | executer.sync { 35 | currentValue = _value 36 | } 37 | return currentValue! 38 | } 39 | set { 40 | executer.async { 41 | self._value = newValue 42 | } 43 | } 44 | } 45 | 46 | public init(_ value: T, queue: DispatchQueue? = nil) { 47 | self._value = value 48 | if let queue = queue { 49 | self.executer = SynchronizedExecuter(queue: queue) 50 | } else { 51 | self.executer = SynchronizedExecuter() 52 | } 53 | } 54 | 55 | public init(_ value: T, executer: SynchronizedExecuter) { 56 | self._value = value 57 | self.executer = executer 58 | } 59 | 60 | /// Mutate value asynchronously. 61 | public func modify(_ mutate: @escaping (inout T) -> Void) { 62 | executer.async { 63 | mutate(&self._value) 64 | } 65 | } 66 | 67 | /// Only sets value if `condition` returns true. Returns true if value 68 | /// is set. Pure convenience. 69 | public func set(_ newValue: T, if condition: (T) -> Bool) -> PropertyDidSet { 70 | var shouldSet = false 71 | executer.sync { 72 | shouldSet = condition(_value) 73 | if shouldSet { 74 | _value = newValue 75 | } 76 | } 77 | return shouldSet 78 | } 79 | 80 | } 81 | 82 | public extension SynchronizedMutableProperty { 83 | 84 | var readonly: SynchronizedProperty { 85 | return SynchronizedProperty(self) 86 | } 87 | } 88 | 89 | public final class SynchronizedProperty: Property { 90 | public typealias T = T_ 91 | 92 | private let mutableProperty: SynchronizedMutableProperty 93 | 94 | public var value: T { 95 | return mutableProperty.value 96 | } 97 | 98 | public init(_ mutableProperty: SynchronizedMutableProperty) { 99 | self.mutableProperty = mutableProperty 100 | } 101 | 102 | } 103 | 104 | public extension ObservableProtocol { 105 | 106 | func shareable(initialValue: ObservedValue) -> ShareableValueStream { 107 | return ShareableValueStream(initialValue: initialValue, sourceObservable: self.any) 108 | } 109 | } 110 | 111 | internal extension MutableProperty where T: Equatable { 112 | 113 | /// Only sets value if `candidate` equals the current value. 114 | /// Returns true if value is set. Pure convenience. 115 | func set(_ newValue: T, ifCurrentValueIs candidate: T) -> PropertyDidSet { 116 | return set(newValue, if: { $0 == candidate }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ResourceParams.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Resource parameters, used in `LoadImpulse`. The most 4 | /// likely use case is to provide data for an API or cache 5 | /// request. It should contain ALL data required for such a 6 | /// request (including authorization tokens, headers, 7 | /// pagination page number, etc). 8 | /// However, Public APIs or locally loaded data might not 9 | /// require any parameters at all - in that case, `NoResourceParams` 10 | /// might come in handy. 11 | public protocol ResourceParams : Equatable { 12 | 13 | /// Returns true if candidate can be used as a 14 | /// cache version of self, or vice versa. 15 | /// In most cases, returning `self == candidate` will be 16 | /// fine. 17 | /// 18 | /// Consider the case that the authenticated user has changed 19 | /// between requests - the old user's data must not be shown 20 | /// anymore. The cache must discard the old stored response. 21 | /// In order to do so, the cache will use this function to 22 | /// compare the cached response's parameters with the new 23 | /// parameters. So, in that case, false must be returned if 24 | /// the new parameters show that the authenticated user's email 25 | /// address or auth token has changed. 26 | /// 27 | /// Another scenario would just be changed request parameters, 28 | /// like the page offset of a paginated datasource. The cache must 29 | /// not continue using an old request's response then. 30 | func isCacheCompatible(_ candidate: Self) -> Bool 31 | } 32 | 33 | /// Empty parameters for use cases without any parametrization 34 | /// needs. 35 | public struct NoResourceParams : ResourceParams, Codable { 36 | public func isCacheCompatible(_ candidate: NoResourceParams) -> Bool { return true } 37 | 38 | public init() {} 39 | 40 | public static let initial = NoResourceParams() 41 | } 42 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ResourceState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// ResourceState describes what state a resource (either loaded from an API, 4 | /// from memory, disk, computed on the fly,...) is in. 5 | public struct ResourceState: Equatable { 6 | 7 | public var provisioningState: ProvisioningState 8 | public var loadImpulse: LoadImpulse

    ? 9 | public var value: EquatableBox? 10 | public var error: E? 11 | 12 | public init( 13 | provisioningState: ProvisioningState, 14 | loadImpulse: LoadImpulse

    ?, 15 | value: EquatableBox?, 16 | error: E? 17 | ) { 18 | self.provisioningState = provisioningState 19 | self.loadImpulse = loadImpulse 20 | self.value = value 21 | self.error = error 22 | } 23 | } 24 | 25 | public extension ResourceState { 26 | 27 | /// Datasource is not ready to provide data. 28 | static var notReady: ResourceState { 29 | return ResourceState(provisioningState: .notReady, loadImpulse: nil, value: nil, error: nil) 30 | } 31 | 32 | /// An error has been encountered in a datasource (e.g. while loading). 33 | /// A value can still be defined (e.g. API call failed, but a cache value is 34 | /// available). 35 | static func error(error: E, 36 | loadImpulse: LoadImpulse

    , 37 | fallbackValueBox: EquatableBox?) -> ResourceState { 38 | return ResourceState(provisioningState: .result, 39 | loadImpulse: loadImpulse, 40 | value: fallbackValueBox, 41 | error: error) 42 | } 43 | 44 | /// A value has been created in a datasource. An error can still be defined 45 | /// (e.g. a cached value has been found, but ) 46 | static func value(valueBox: EquatableBox, 47 | loadImpulse: LoadImpulse

    , 48 | fallbackError: E?) -> ResourceState { 49 | return ResourceState( 50 | provisioningState: .result, 51 | loadImpulse: loadImpulse, 52 | value: valueBox, 53 | error: fallbackError 54 | ) 55 | } 56 | 57 | /// The emitting datasource is loading, and has a fallbackValue (e.g. from a cache), or 58 | /// a fallback error, or both. 59 | static func loading(loadImpulse: LoadImpulse

    , 60 | fallbackValueBox: EquatableBox?, 61 | fallbackError: E?) -> ResourceState { 62 | return ResourceState( 63 | provisioningState: .loading, 64 | loadImpulse: loadImpulse, 65 | value: fallbackValueBox, 66 | error: fallbackError 67 | ) 68 | } 69 | 70 | func hasLoadedSuccessfully(for loadImpulse: LoadImpulse

    ) -> Bool { 71 | switch provisioningState { 72 | case .loading, .notReady: 73 | return false 74 | case .result: 75 | if cacheCompatibleValue(for: loadImpulse) != nil { 76 | return error == nil 77 | } else { 78 | return false 79 | } 80 | } 81 | } 82 | 83 | func cacheCompatibleValue(for loadImpulse: LoadImpulse

    ) -> EquatableBox? { 84 | guard let value = self.value, 85 | let selfLoadImpulse = self.loadImpulse, 86 | selfLoadImpulse.isCacheCompatible(loadImpulse) else { 87 | return nil 88 | } 89 | return value 90 | } 91 | 92 | func cacheCompatibleError(for loadImpulse: LoadImpulse

    ) -> E? { 93 | guard let error = self.error, 94 | let selfLoadImpulse = self.loadImpulse, 95 | selfLoadImpulse.isCacheCompatible(loadImpulse) else { 96 | return nil 97 | } 98 | return error 99 | } 100 | 101 | } 102 | 103 | /// Type Int because it gives Equatable and Codable conformance for free 104 | public enum ProvisioningState: Int, Equatable, Codable { 105 | case notReady 106 | case loading 107 | case result 108 | } 109 | 110 | extension ResourceState: Codable where Value: Codable, P: Codable, E: Codable {} 111 | 112 | public protocol ResourceError: Error, Equatable { 113 | 114 | var errorMessage: StateErrorMessage { get } 115 | 116 | init(message: StateErrorMessage) 117 | } 118 | 119 | public struct NoResourceError: ResourceError { 120 | 121 | public var errorMessage: StateErrorMessage { return .default } 122 | 123 | public init(message: StateErrorMessage) {} 124 | } 125 | 126 | public enum StateErrorMessage: Equatable, Codable { 127 | case `default` 128 | case message(String) 129 | 130 | enum CodingKeys: String, CodingKey { 131 | case enumCaseKey = "type" 132 | case `default` 133 | case message 134 | } 135 | 136 | public init(from decoder: Decoder) throws { 137 | let container = try decoder.container(keyedBy: CodingKeys.self) 138 | 139 | let enumCaseString = try container.decode(String.self, forKey: .enumCaseKey) 140 | guard let enumCase = CodingKeys(rawValue: enumCaseString) else { 141 | throw DecodingError.dataCorrupted( 142 | .init( 143 | codingPath: decoder.codingPath, 144 | debugDescription: "Unknown enum case '\(enumCaseString)'" 145 | ) 146 | ) 147 | } 148 | 149 | switch enumCase { 150 | case .default: 151 | self = .default 152 | case .message: 153 | if let message = try? container.decode(String.self, forKey: .message) { 154 | self = .message(message) 155 | } else { 156 | self = .default 157 | } 158 | default: throw DecodingError.dataCorrupted( 159 | .init( 160 | codingPath: decoder.codingPath, 161 | debugDescription: "Unknown enum case '\(enumCase)'") 162 | ) 163 | } 164 | } 165 | 166 | public func encode(to encoder: Encoder) throws { 167 | var container = encoder.container(keyedBy: CodingKeys.self) 168 | 169 | switch self { 170 | case let .message(message): 171 | try container.encode(CodingKeys.message.rawValue, forKey: .enumCaseKey) 172 | try container.encode(message, forKey: .message) 173 | case .default: 174 | try container.encode(CodingKeys.default.rawValue, forKey: .enumCaseKey) 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ResourceStatePersister.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ResourceStatePersister { 4 | associatedtype Value: Any 5 | associatedtype P: ResourceParams 6 | associatedtype E: ResourceError 7 | typealias PersistedState = ResourceState 8 | 9 | func persist(_ state: PersistedState) 10 | func load(_ parameters: P) -> PersistedState? 11 | func purge() 12 | } 13 | 14 | public extension ResourceStatePersister { 15 | var any: AnyResourceStatePersister { 16 | return AnyResourceStatePersister(self) 17 | } 18 | } 19 | 20 | public struct AnyResourceStatePersister 21 | : ResourceStatePersister { 22 | public typealias Value = Value_ 23 | public typealias P = P_ 24 | public typealias E = E_ 25 | 26 | private let _persist: (PersistedState) -> Void 27 | private let _load: (P) -> PersistedState? 28 | private let _purge: () -> Void 29 | 30 | public init(_ persister: SP) where SP.PersistedState == PersistedState { 31 | self._persist = persister.persist 32 | self._load = persister.load 33 | self._purge = persister.purge 34 | } 35 | 36 | public func persist(_ state: PersistedState) { 37 | _persist(state) 38 | } 39 | 40 | public func load(_ parameters: P) -> PersistedState? { 41 | return _load(parameters) 42 | } 43 | 44 | public func purge() { 45 | _purge() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ShareableValueStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Retains the last emitted value of the included `Observable`. 4 | /// Calling `observe(_)` returns the current or initial value 5 | /// synchronously on the queue on which `observe(_)` is called. 6 | /// After that, `sourceObservable`'s stream of values is forwarded. 7 | /// 8 | /// Similar to ReactiveSwift.Property. 9 | public final class ShareableValueStream: ObservableProtocol, Property { 10 | public typealias T = T_ 11 | public typealias ObservedValue = T 12 | public typealias ValuesOverTime = (ObservedValue) -> Void 13 | 14 | public var value: ObservedValue { 15 | return mutableLastValue.value 16 | } 17 | private let mutableLastValue: SynchronizedMutableProperty 18 | private let broadcastObservable = BroadcastObservable() 19 | private let disposeBag = DisposeBag() 20 | 21 | public init(initialValue: ObservedValue, sourceObservable: AnyObservable) { 22 | mutableLastValue = SynchronizedMutableProperty(initialValue) 23 | 24 | sourceObservable.observe { [weak self] value in 25 | self?.mutableLastValue.value = value 26 | self?.broadcastObservable.emit(value) 27 | }.disposed(by: disposeBag) 28 | } 29 | 30 | public func observe(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable { 31 | 32 | // Send current value 33 | valuesOverTime(value) 34 | 35 | return observeWithoutCurrentValue(valuesOverTime) 36 | } 37 | 38 | /// In some cases, the first value is not desired. 39 | /// We want "send first value upon observation synchronously" 40 | /// to be the standard behavior in observe(_), so this 41 | /// separate method is needed. 42 | public func observeWithoutCurrentValue(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable { 43 | 44 | // Retain self until returned disposable is disposed of. Or else, 45 | // this ObservableProperty might get deallocated even though there 46 | // are still observers. 47 | return CompositeDisposable(broadcastObservable.observe(valuesOverTime), 48 | objectToRetain: self) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/SourcererExtensionsProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SourcererExtensionsProvider {} 4 | 5 | public extension SourcererExtensionsProvider { 6 | var sourcerer: SourcererExtension { 7 | return SourcererExtension(self) 8 | } 9 | 10 | static var sourcerer: SourcererExtension.Type { 11 | return SourcererExtension.self 12 | } 13 | } 14 | 15 | /// A proxy which hosts reactive extensions of `Base`. 16 | public struct SourcererExtension { 17 | /// The `Base` instance the extensions would be invoked with. 18 | public let base: Base 19 | 20 | /// Construct a proxy 21 | /// 22 | /// - parameters: 23 | /// - base: The object to be proxied. 24 | fileprivate init(_ base: Base) { 25 | self.base = base 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/SynchronizedExecuter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Thread-safe executions by employing dispatch queues. 4 | /// 5 | /// - Read more here: http://www.fieryrobot.com/blog/2010/09/01/synchronization-using-grand-central-dispatch/ 6 | public struct SynchronizedExecuter { 7 | 8 | public let queue: DispatchQueue 9 | public let dispatchSpecificKey = DispatchSpecificKey() 10 | public let dispatchSpecificValue = UInt8.max 11 | 12 | public init(queue: DispatchQueue? = nil, label: String = "DataSourcerer-SynchronizedExecuter") { 13 | if let queue = queue { 14 | self.queue = queue 15 | } else { 16 | self.queue = DispatchQueue(label: label) 17 | } 18 | self.queue.setSpecific(key: dispatchSpecificKey, value: dispatchSpecificValue) 19 | } 20 | 21 | public func async(_ execute: @escaping () -> Void) { 22 | queue.async(execute: execute) 23 | } 24 | 25 | public func sync(_ execute: () -> Void) { 26 | if DispatchQueue.getSpecific(key: dispatchSpecificKey) == dispatchSpecificValue { 27 | execute() 28 | } else { 29 | queue.sync(execute: execute) 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ValueStream+URLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension ValueStream { 4 | 5 | /// Loads data with the URLRequests produced by `URLRequestMaker`, 6 | /// whenever `loadImpulseEmitter` emits a load impulse. If 7 | /// any error is encountered, an error is sent instead, using 8 | /// `errorMaker`. 9 | init( 10 | loadStatesWithURLRequest URLRequestMaker: @escaping (LoadImpulse

    ) throws -> URLRequest, 11 | mapErrorString: @escaping (String) -> E, 12 | loadImpulseEmitter: AnyLoadImpulseEmitter

    13 | ) where ObservedValue == ResourceState, Value: Codable { 14 | 15 | typealias DatasourceState = ObservedValue 16 | 17 | self.init { sendState, disposable in 18 | 19 | disposable += loadImpulseEmitter.observe { loadImpulse in 20 | 21 | func sendError(_ error: E) { 22 | sendState(DatasourceState.error( 23 | error: error, 24 | loadImpulse: loadImpulse, 25 | fallbackValueBox: nil 26 | )) 27 | } 28 | 29 | guard let urlRequest = try? URLRequestMaker(loadImpulse) else { 30 | sendError(mapErrorString("Request could not be generated")) 31 | return 32 | } 33 | 34 | let loadingState = DatasourceState.loading( 35 | loadImpulse: loadImpulse, 36 | fallbackValueBox: nil, 37 | fallbackError: nil 38 | ) 39 | 40 | sendState(loadingState) 41 | 42 | let config = URLSessionConfiguration.default 43 | let session = URLSession(configuration: config) 44 | 45 | let task = session.dataTask(with: urlRequest) { data, _, error in 46 | 47 | guard error == nil else { 48 | sendError(mapErrorString(""" 49 | Request could not be loaded - 50 | we are too lazy to parse the actual error yet ;) 51 | """)) 52 | return 53 | } 54 | 55 | // make sure we got data 56 | guard let responseData = data else { 57 | sendError(mapErrorString("responseData is nil")) 58 | return 59 | } 60 | 61 | do { 62 | let value = try JSONDecoder.decode(responseData, 63 | to: Value.self) 64 | let state = DatasourceState.value(valueBox: EquatableBox(value), 65 | loadImpulse: loadImpulse, 66 | fallbackError: nil) 67 | sendState(state) 68 | } catch { 69 | sendError(mapErrorString(""" 70 | Value cannot be parsed: \(String(describing: error)) 71 | """)) 72 | return 73 | } 74 | } 75 | task.resume() 76 | 77 | disposable += ActionDisposable { [weak task] in 78 | task?.cancel() 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Core/ValueStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Provides an observable stream of values. 4 | /// 5 | /// Will only start work after `observe(_)` is called. 6 | /// 7 | /// Analogy to ReactiveSwift: ValueStreams are like SignalProducers, 8 | /// which are "cold" (no work performed) until they are started. 9 | /// SignalProducer.init(_ startHandler) is very similar to 10 | /// ValueStream.init(_ observeHandler). 11 | /// 12 | /// Analogy to RxSwift/ReactiveX: Insert example :) 13 | public struct ValueStream: ObservableProtocol { 14 | public typealias ObservedValue = Value 15 | public typealias ValuesOverTime = (ObservedValue) -> Void 16 | public typealias ObserveHandler = (@escaping ValuesOverTime, CompositeDisposable) -> Void 17 | 18 | private let observeHandler: ObserveHandler 19 | 20 | public init(_ observeHandler: @escaping ObserveHandler) { 21 | self.observeHandler = observeHandler 22 | } 23 | 24 | public func observe(_ valuesOverTime: @escaping ValuesOverTime) -> Disposable { 25 | 26 | let disposable = CompositeDisposable() 27 | observeHandler(valuesOverTime, disposable) 28 | return disposable 29 | } 30 | } 31 | 32 | // MARK: Closure support 33 | 34 | public extension ValueStream { 35 | 36 | /// Initializes a ValueStream with a closure that generates 37 | /// `State`s. 38 | init( 39 | makeStatesWithClosure 40 | generateState: @escaping (LoadImpulse

    , @escaping ValuesOverTime) -> Disposable, 41 | loadImpulseEmitter: AnyLoadImpulseEmitter

    42 | ) where ObservedValue == ResourceState { 43 | 44 | self.init { sendState, disposable in 45 | 46 | disposable += loadImpulseEmitter.observe { loadImpulse in 47 | disposable += generateState(loadImpulse, sendState) 48 | } 49 | } 50 | } 51 | } 52 | 53 | // MARK: Load from ResourceStatePersister 54 | 55 | public extension ValueStream { 56 | 57 | /// Sends a persisted state from `persister`, every time 58 | /// `loadImpulseEmitter` sends an impulse. If an error occurs 59 | /// while loading (e.g. deserialization error), `cacheLoadError` 60 | /// is sent instead. 61 | init( 62 | loadStatesFromPersister persister: AnyResourceStatePersister, 63 | loadImpulseEmitter: AnyLoadImpulseEmitter

    , 64 | cacheLoadError: E 65 | ) where ObservedValue == ResourceState { 66 | 67 | self.init { sendState, disposable in 68 | 69 | disposable += loadImpulseEmitter.observe { loadImpulse in 70 | guard let cached = persister.load(loadImpulse.params) else { 71 | let error = ResourceState.error(error: cacheLoadError, 72 | loadImpulse: loadImpulse, 73 | fallbackValueBox: nil) 74 | sendState(error) 75 | return 76 | } 77 | 78 | sendState(cached) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/CollectionReusableViewProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public enum SimpleCollectionReusableViewProducer 5 | : SupplementaryItemModelProducer { 6 | 7 | public typealias Item = ReusableView 8 | public typealias ProducedView = UICollectionReusableView 9 | public typealias ContainingView = UICollectionView 10 | 11 | public typealias UICollectionViewDequeueIdentifier = String 12 | 13 | // Cell class registration is performed automatically: 14 | case classAndIdentifier( 15 | class: AnyClass, 16 | kind: Kind, 17 | identifier: UICollectionViewDequeueIdentifier, 18 | configure: (ReusableView, UICollectionReusableView) -> Void 19 | ) 20 | 21 | case nibAndIdentifier( 22 | nib: UINib, 23 | kind: Kind, 24 | identifier: UICollectionViewDequeueIdentifier, 25 | configure: (ReusableView, UICollectionReusableView) -> Void 26 | ) 27 | 28 | public func view(containingView: UICollectionView, item: ReusableView, for indexPath: IndexPath) 29 | -> ProducedView { 30 | switch self { 31 | case let .classAndIdentifier(_, kind, identifier, configure): 32 | let supplementaryView = containingView.dequeueReusableSupplementaryView( 33 | ofKind: kind.description, 34 | withReuseIdentifier: identifier, 35 | for: indexPath 36 | ) 37 | configure(item, supplementaryView) 38 | return supplementaryView 39 | case let .nibAndIdentifier(_, kind, identifier, configure): 40 | let supplementaryView = containingView.dequeueReusableSupplementaryView( 41 | ofKind: kind.description, 42 | withReuseIdentifier: identifier, 43 | for: indexPath 44 | ) 45 | configure(item, supplementaryView) 46 | return supplementaryView 47 | } 48 | } 49 | 50 | public func register(at containingView: UICollectionView) { 51 | switch self { 52 | case let .classAndIdentifier(clazz, kind, identifier, _): 53 | containingView.register(clazz, 54 | forSupplementaryViewOfKind: kind.description, 55 | withReuseIdentifier: identifier) 56 | case let .nibAndIdentifier(nib, kind, identifier, _): 57 | containingView.register(nib, 58 | forSupplementaryViewOfKind: kind.description, 59 | withReuseIdentifier: identifier) 60 | } 61 | } 62 | 63 | public var defaultView: UICollectionReusableView { return UICollectionReusableView() } 64 | 65 | public enum Kind: CustomStringConvertible { 66 | case sectionHeader 67 | case sectionFooter 68 | case custom(String) 69 | 70 | public var description: String { 71 | switch self { 72 | case .sectionHeader: return UICollectionView.elementKindSectionHeader 73 | case .sectionFooter: return UICollectionView.elementKindSectionFooter 74 | case let .custom(kind): return kind 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/CollectionViewCellProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public enum SimpleCollectionViewCellProducer: ItemViewProducer { 5 | public typealias ItemModel = Cell 6 | public typealias ProducedView = UICollectionViewCell 7 | public typealias ContainingView = UICollectionView 8 | 9 | public typealias UICollectionViewDequeueIdentifier = String 10 | 11 | // Cell class registration is performed automatically: 12 | case classAndIdentifier( 13 | class: UICollectionViewCell.Type, 14 | identifier: UICollectionViewDequeueIdentifier, 15 | configure: (IndexPath, Cell, UICollectionViewCell) -> Void 16 | ) 17 | 18 | case nibAndIdentifier( 19 | nib: UINib, 20 | identifier: UICollectionViewDequeueIdentifier, 21 | configure: (IndexPath, Cell, UICollectionViewCell) -> Void 22 | ) 23 | 24 | public func view(containingView: UICollectionView, item: Cell, for indexPath: IndexPath) 25 | -> ProducedView { 26 | switch self { 27 | case let .classAndIdentifier(_, identifier, configure): 28 | let collectionViewCell = containingView.dequeueReusableCell(withReuseIdentifier: identifier, 29 | for: indexPath) 30 | configure(indexPath, item, collectionViewCell) 31 | return collectionViewCell 32 | case let .nibAndIdentifier(_, identifier, configure): 33 | let collectionViewCell = containingView.dequeueReusableCell(withReuseIdentifier: identifier, 34 | for: indexPath) 35 | configure(indexPath, item, collectionViewCell) 36 | return collectionViewCell 37 | } 38 | } 39 | 40 | public func register(at containingView: UICollectionView) { 41 | switch self { 42 | case let .classAndIdentifier(clazz, identifier, _): 43 | containingView.register(clazz, forCellWithReuseIdentifier: identifier) 44 | case let .nibAndIdentifier(nib, identifier, _): 45 | containingView.register(nib, forCellWithReuseIdentifier: identifier) 46 | } 47 | } 48 | 49 | // Will cause a crash if used: 50 | public var defaultView: UICollectionViewCell { return UICollectionViewCell() } 51 | } 52 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/IdiomaticErrorTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public class IdiomaticErrorTableViewCell : UITableViewCell { 5 | 6 | public var content: StateErrorMessage = .default { 7 | didSet { 8 | refreshContent() 9 | } 10 | } 11 | 12 | public var defaultErrorMessage: String { 13 | return NSLocalizedString(""" 14 | An error occurrec while loading.\n 15 | Please try again! 16 | """, comment: "") 17 | } 18 | 19 | public lazy var label: UILabel = { 20 | let label = UILabel() 21 | label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) 22 | label.textColor = .gray 23 | label.textAlignment = .center 24 | label.numberOfLines = 0 25 | 26 | self.contentView.addSubview(label) 27 | label.translatesAutoresizingMaskIntoConstraints = false 28 | label.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 20).isActive = true 29 | label.leftAnchor.constraint(equalTo: self.contentView.leftAnchor, constant: 20).isActive = true 30 | label.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -20).isActive = true 31 | label.rightAnchor.constraint(equalTo: self.contentView.rightAnchor, constant: -20).isActive = true 32 | label.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true 33 | 34 | return label 35 | }() 36 | 37 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 38 | super.init(style: style, reuseIdentifier: reuseIdentifier) 39 | commonInit() 40 | } 41 | 42 | public init() { 43 | super.init(style: .default, reuseIdentifier: nil) 44 | commonInit() 45 | } 46 | 47 | @available(*, unavailable) 48 | public required init?(coder aDecoder: NSCoder) { 49 | fatalError("IdiomaticErrorTableViewCell cannot be used from a storyboard") 50 | } 51 | 52 | override public func willMove(toSuperview newSuperview: UIView?) { 53 | super.willMove(toSuperview: newSuperview) 54 | refreshContent() 55 | } 56 | 57 | func refreshContent() { 58 | label.text = { 59 | switch content { 60 | case .default: 61 | return defaultErrorMessage 62 | case let .message(string): 63 | return string 64 | } 65 | }() 66 | } 67 | 68 | public func commonInit() { 69 | backgroundColor = .white 70 | separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 9_999) 71 | selectionStyle = .none 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/IdiomaticLoadingTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class IdiomaticLoadingTableViewCell : UITableViewCell { 5 | 6 | lazy var loadingIndicatorView: UIActivityIndicatorView = { 7 | let loadingIndicatorView = UIActivityIndicatorView(style: .gray) 8 | loadingIndicatorView.hidesWhenStopped = false 9 | self.contentView.addSubview(loadingIndicatorView) 10 | 11 | loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = false 12 | loadingIndicatorView.topAnchor.constraint(equalTo: self.contentView.topAnchor, 13 | constant: 20).isActive = true 14 | loadingIndicatorView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor).isActive = true 15 | loadingIndicatorView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, 16 | constant: -20).isActive = true 17 | 18 | return loadingIndicatorView 19 | }() 20 | 21 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 22 | super.init(style: style, reuseIdentifier: reuseIdentifier) 23 | 24 | backgroundColor = .white 25 | separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 9_999) 26 | selectionStyle = .none 27 | startAnimating() 28 | } 29 | 30 | func startAnimating() { 31 | loadingIndicatorView.startAnimating() 32 | } 33 | 34 | override func layoutSubviews() { 35 | super.layoutSubviews() 36 | } 37 | 38 | override func prepareForReuse() { 39 | super.prepareForReuse() 40 | startAnimating() 41 | } 42 | 43 | @available(*, unavailable) 44 | required init?(coder aDecoder: NSCoder) { 45 | fatalError("IdiomaticLoadingTableViewCell cannot be used from a storyboard") 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/ListSections+Dwifft.swift: -------------------------------------------------------------------------------- 1 | import Dwifft 2 | import Foundation 3 | 4 | public extension ListViewState { 5 | 6 | var sectionedValues: SectionedValues { 7 | return SectionedValues((sectionsWithItems ?? []).map({ ($0.section, $0.items) })) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/SimpleCollectionViewDatasource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | //open class SimpleCollectionViewDatasource: NSObject, UICollectionViewDataSource, UICollectionViewDelegate { 6 | // public typealias Configuration = ListViewDatasourceConfiguration 7 | // 9 | // 10 | // private let configuration: Configuration 11 | // 12 | // public init(configuration: Configuration, collectionView: UICollectionView) { 13 | // self.configuration = configuration 14 | // registerItemViews(with: collectionView) 15 | // } 16 | // 17 | // private func registerItemViews(with collectionView: UICollectionView) { 18 | // configuration.itemViewsProducer.registerAtContainingView(collectionView) 19 | // configuration.supplementaryItemModelViewAdapter.registerAtContainingView(collectionView) 20 | // } 21 | // 22 | // public func numberOfSections(in collectionView: UICollectionView) -> Int { 23 | // return configuration.sections.sectionedValues.sectionsAndValues.count 24 | // } 25 | // 26 | // public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) 27 | // -> Int { 28 | // return configuration.items(in: section).count 29 | // } 30 | // 31 | // public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) 32 | // -> UICollectionViewCell { 33 | // 34 | // return configuration.itemView(at: indexPath, in: collectionView) 35 | // } 36 | // 37 | // public func indexTitles(for collectionView: UICollectionView) -> [String]? { 38 | // 39 | // return configuration.sectionIndexTitles?() 40 | // } 41 | // 42 | // public func collectionView(_ collectionView: UICollectionView, 43 | // viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) 44 | // -> UICollectionReusableView { 45 | // 46 | // guard let view = configuration.supplementaryItemModelViews(at: indexPath, in: collectionView) else { 47 | // assert(false, "set ListViewDatasourceConfiguration.supplementaryItemModelAtIndexPath !") 48 | // return UICollectionReusableView() 49 | // } 50 | // 51 | // return view 52 | // } 53 | // 54 | // public func collectionView(_ collectionView: UICollectionView, 55 | // indexPathForIndexTitle title: String, at index: Int) -> IndexPath { 56 | // 57 | // return configuration.indexPathForIndexTitle?(title, index) ?? IndexPath() 58 | // } 59 | // 60 | //} 61 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/SingleSectionTableViewController.swift: -------------------------------------------------------------------------------- 1 | import Dwifft 2 | import Foundation 3 | 4 | open class SingleSectionTableViewController 5 | 7 | : UIViewController 8 | where CellModelType.E == E, HeaderItem.E == HeaderItemError, FooterItem.E == FooterItemError { 9 | 10 | public typealias ValuesObservable = AnyObservable 11 | public typealias ViewState = SingleSectionListViewState 12 | public typealias Configuration = ListViewDatasourceConfiguration 13 | 15 | public typealias ChangeCellsInView = (UITableView, _ previous: ViewState, _ next: ViewState) -> Void 16 | 17 | open var refreshControl: UIRefreshControl? 18 | private let disposeBag = DisposeBag() 19 | 20 | public lazy var tableView: UITableView = { 21 | let view = UITableView(frame: .zero, style: self.tableViewStyle) 22 | self.view.addSubview(view) 23 | view.translatesAutoresizingMaskIntoConstraints = false 24 | view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true 25 | view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true 26 | view.rightAnchor.constraint(equalTo: self.view.rightAnchor).isActive = true 27 | view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 28 | let footerView = UIView(frame: .zero) 29 | view.tableFooterView = footerView 30 | 31 | return view 32 | }() 33 | 34 | public var addEmptyViewAboveTableView = true // To prevent tableview insets bugs in iOS10 35 | public var tableViewStyle = UITableView.Style.plain 36 | public var estimatedRowHeight: CGFloat = 75 37 | public var supportPullToRefresh = true 38 | public var animateTableViewUpdates = true 39 | public var pullToRefresh: (() -> Void)? 40 | public var willChangeCellsInView: ChangeCellsInView? 41 | public var didChangeCellsInView: ChangeCellsInView? 42 | 43 | open var isViewVisible: Bool { 44 | return viewIfLoaded?.window != nil && view.alpha > 0.001 45 | } 46 | 47 | private let configuration: Configuration 48 | public lazy var tableViewDatasource = TableViewDatasource( 49 | configuration: configuration, 50 | tableView: tableView 51 | ) 52 | 53 | private var tableViewDiffCalculator: SingleSectionTableViewDiffCalculator? 54 | 55 | public init(configuration: Configuration) { 56 | self.configuration = configuration 57 | super.init(nibName: nil, bundle: nil) 58 | } 59 | 60 | @available(*, unavailable) 61 | required public init?(coder aDecoder: NSCoder) { 62 | fatalError("Storyboards cannot be used with this class") 63 | } 64 | 65 | override open func viewDidLoad() { 66 | super.viewDidLoad() 67 | 68 | extendedLayoutIncludesOpaqueBars = true 69 | 70 | if addEmptyViewAboveTableView { 71 | view.addSubview(UIView()) 72 | } 73 | 74 | tableView.delegate = tableViewDatasource 75 | tableView.dataSource = tableViewDatasource 76 | tableView.tableFooterView = UIView(frame: .zero) 77 | 78 | tableView.rowHeight = UITableView.automaticDimension 79 | tableView.estimatedRowHeight = estimatedRowHeight 80 | 81 | if #available(iOS 11.0, *) { 82 | tableView.insetsContentViewsToSafeArea = true 83 | } 84 | 85 | if supportPullToRefresh { 86 | let refreshControl = UIRefreshControl() 87 | refreshControl.addTarget(self, action: #selector(doPullToRefresh), for: .valueChanged) 88 | tableView.addSubview(refreshControl) 89 | tableView.sendSubviewToBack(refreshControl) 90 | self.refreshControl = refreshControl 91 | } 92 | 93 | let cellsProperty = tableViewDatasource.cellsProperty 94 | var previousCells = cellsProperty.value 95 | 96 | // Update table with most current cells 97 | cellsProperty 98 | .skipRepeats { lhs, rhs -> Bool in 99 | return lhs.items == rhs.items 100 | } 101 | .observe { [weak self] cells in 102 | self?.updateCells(previous: previousCells, next: cells) 103 | previousCells = cells 104 | } 105 | .disposed(by: disposeBag) 106 | } 107 | 108 | private func updateCells(previous: ViewState, next: ViewState) { 109 | 110 | switch previous { 111 | case let .readyToDisplay(_, previousCells) where isViewVisible && animateTableViewUpdates: 112 | if tableViewDiffCalculator == nil { 113 | // Use previous cells as initial values such that "next" cells are 114 | // inserted with animations 115 | tableViewDiffCalculator = createTableViewDiffCalculator(initial: previousCells) 116 | } 117 | willChangeCellsInView?(tableView, previous, next) 118 | tableViewDiffCalculator?.rows = next.items ?? [] 119 | didChangeCellsInView?(tableView, previous, next) 120 | case .readyToDisplay, .notReady: 121 | // Animations disabled or view invisible - skip animations. 122 | self.tableViewDiffCalculator = nil 123 | DispatchQueue.main.async { [weak self] in 124 | guard let self = self else { return } 125 | self.willChangeCellsInView?(self.tableView, previous, next) 126 | self.tableView.reloadData() 127 | self.didChangeCellsInView?(self.tableView, previous, next) 128 | } 129 | } 130 | } 131 | 132 | private func createTableViewDiffCalculator(initial: [CellModelType]) 133 | -> SingleSectionTableViewDiffCalculator { 134 | let calculator = SingleSectionTableViewDiffCalculator( 135 | tableView: tableView, 136 | initialRows: initial 137 | ) 138 | calculator.insertionAnimation = .fade 139 | calculator.deletionAnimation = .fade 140 | return calculator 141 | } 142 | 143 | @objc 144 | func doPullToRefresh() { 145 | pullToRefresh?() 146 | } 147 | 148 | public func onPullToRefresh(_ pullToRefresh: @escaping () -> Void) 149 | -> SingleSectionTableViewController { 150 | 151 | self.pullToRefresh = pullToRefresh 152 | return self 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/TableHeaderViewProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public protocol TableHeaderViewProducer : SupplementaryItemModelProducer 5 | where ProducedView == UIView, ContainingView == UITableView {} 6 | 7 | public struct SimpleTableHeaderViewProducer: TableHeaderViewProducer { 8 | public typealias Item = Item_ 9 | public typealias ProducedView = UIView 10 | public typealias ContainingView = UITableView 11 | 12 | public typealias UICollectionViewDequeueIdentifier = String 13 | 14 | private let instantiate: (Item, IndexPath) -> UIView 15 | 16 | public func view(containingView: UITableView, item: Item, for indexPath: IndexPath) 17 | -> ProducedView { 18 | return instantiate(item, indexPath) 19 | } 20 | 21 | public func register(at containingView: UITableView) { } 22 | 23 | public var defaultView: ProducedView { return UIView() } 24 | } 25 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/TableViewCellProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public enum SimpleTableViewCellProducer: ItemViewProducer { 5 | public typealias ItemModel = Cell 6 | public typealias ProducedView = UITableViewCell 7 | public typealias ContainingView = UITableView 8 | public typealias TableViewCellDequeueIdentifier = String 9 | 10 | // Cell class registration is performed automatically: 11 | case classAndIdentifier(class: UITableViewCell.Type, 12 | identifier: TableViewCellDequeueIdentifier, 13 | configure: (Cell, UITableViewCell) -> Void) 14 | 15 | case nibAndIdentifier(nib: UINib, 16 | identifier: TableViewCellDequeueIdentifier, 17 | configure: (Cell, UITableViewCell) -> Void) 18 | 19 | // No cell class registration is performed: 20 | case instantiate((Cell) -> UITableViewCell) 21 | 22 | public func view(containingView: UITableView, item: Cell, for indexPath: IndexPath) -> ProducedView { 23 | switch self { 24 | case let .classAndIdentifier(_, identifier, configure): 25 | let tableViewCell = containingView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) 26 | configure(item, tableViewCell) 27 | return tableViewCell 28 | case let .nibAndIdentifier(_, identifier, configure): 29 | let tableViewCell = containingView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) 30 | configure(item, tableViewCell) 31 | return tableViewCell 32 | case let .instantiate(instantiate): 33 | return instantiate(item) 34 | } 35 | } 36 | 37 | public func register(at containingView: UITableView) { 38 | switch self { 39 | case let .classAndIdentifier(clazz, identifier, _): 40 | containingView.register(clazz, forCellReuseIdentifier: identifier) 41 | case let .nibAndIdentifier(nib, identifier, _): 42 | containingView.register(nib, forCellReuseIdentifier: identifier) 43 | case .instantiate: 44 | break 45 | } 46 | } 47 | 48 | public var defaultView: UITableViewCell { return UITableViewCell() } 49 | } 50 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List-UIKit/UIRefreshControl+EndRefreshing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension UIRefreshControl : SourcererExtensionsProvider {} 5 | 6 | public extension SourcererExtension where Base: UIRefreshControl { 7 | 8 | func endRefreshingOnLoadingEnded( 9 | _ datasource: Datasource 10 | ) -> Disposable { 11 | 12 | return datasource.state 13 | .loadingEnded() 14 | .observeOnUIThread() 15 | .observe { [weak base] _ in 16 | base?.endRefreshing() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/ItemModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ItemModel: Equatable { 4 | associatedtype E: ResourceError 5 | 6 | // Required to display configuration or system errors 7 | // for easier debugging. 8 | init(error: E) 9 | } 10 | 11 | public protocol IdiomaticStateError: ResourceError { 12 | init(message: StateErrorMessage) 13 | } 14 | 15 | public enum IdiomaticItemModel : ItemModel { 16 | case baseItem(BaseItem) 17 | case loading 18 | case error(BaseItem.E) 19 | case noResults(String) 20 | 21 | public init(error: BaseItem.E) { 22 | self = .error(error) 23 | } 24 | } 25 | 26 | public protocol HashableItemModel : ItemModel, Hashable { } 27 | 28 | // MARK: SingleSectionListViewState 29 | 30 | public enum SingleSectionListViewState: 31 | Equatable { 32 | 33 | case notReady 34 | case readyToDisplay(ResourceState, [LI]) 35 | 36 | public var items: [LI]? { 37 | switch self { 38 | case .notReady: return nil 39 | case let .readyToDisplay(_, items): return items 40 | } 41 | } 42 | 43 | init(listViewState: ListViewState) { 44 | switch listViewState { 45 | case .notReady: 46 | self = .notReady 47 | case let .readyToDisplay(resourceState, sectionsWithItems): 48 | self = .readyToDisplay( 49 | resourceState, 50 | sectionsWithItems.first?.items ?? [] 51 | ) 52 | } 53 | } 54 | } 55 | 56 | public struct SectionAndItems: Equatable { 57 | public let section: Section 58 | public let items: [Item] 59 | 60 | public init(_ section: Section, _ items: [Item]) { 61 | self.section = section 62 | self.items = items 63 | } 64 | } 65 | 66 | public enum ListViewState 67 | : Equatable { 69 | 70 | case notReady 71 | case readyToDisplay( 72 | ResourceState, 73 | [SectionAndItems] 74 | ) 75 | 76 | public var sectionsWithItems: [SectionAndItems]? { 77 | switch self { 78 | case .notReady: return nil 79 | case let .readyToDisplay(_, sectionsWithItems): return sectionsWithItems 80 | } 81 | } 82 | } 83 | 84 | public extension ListViewState { 85 | 86 | func doCellsDiffer(other: ListViewState) -> Bool { 87 | switch (self, other) { 88 | case (.notReady, .notReady): 89 | return false 90 | case (.notReady, .readyToDisplay), (.readyToDisplay, .notReady): 91 | return true 92 | case let (.readyToDisplay(_, lhsSectionsAndItems), 93 | .readyToDisplay(_, rhsSectionsAndItems)): 94 | return lhsSectionsAndItems == rhsSectionsAndItems 95 | } 96 | } 97 | } 98 | 99 | public extension SingleSectionListViewState { 100 | 101 | func doCellsDiffer(other: SingleSectionListViewState) -> Bool { 102 | switch (self, other) { 103 | case (.notReady, .notReady): 104 | return false 105 | case (.notReady, .readyToDisplay), (.readyToDisplay, .notReady): 106 | return true 107 | case let (.readyToDisplay(_, lhsSectionsAndItems), 108 | .readyToDisplay(_, rhsSectionsAndItems)): 109 | return lhsSectionsAndItems == rhsSectionsAndItems 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/ItemModelsProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ItemModelsProducer 4 | 5 | where ItemModelType.E == E { 6 | 7 | public typealias StateToListViewState = 8 | (ResourceState, 9 | ValueToListViewStateTransformer) 10 | -> ListViewState 11 | 12 | internal let stateToListViewState: StateToListViewState 13 | internal let valueToListViewStateTransformer: 14 | ValueToListViewStateTransformer 15 | 16 | public init( 17 | stateToListViewState: @escaping StateToListViewState, 18 | valueToListViewStateTransformer: 19 | ValueToListViewStateTransformer 20 | ) { 21 | 22 | self.stateToListViewState = stateToListViewState 23 | self.valueToListViewStateTransformer = valueToListViewStateTransformer 24 | } 25 | 26 | public init( 27 | baseValueToListViewStateTransformer: 28 | ValueToListViewStateTransformer 29 | ) { 30 | 31 | self.stateToListViewState = { state, valueToListViewStateTransformer in 32 | if let value = state.value?.value { 33 | return valueToListViewStateTransformer.valueToListViewState(value, state) 34 | } else { 35 | return .notReady 36 | } 37 | } 38 | self.valueToListViewStateTransformer = baseValueToListViewStateTransformer 39 | } 40 | 41 | public static func withSingleSectionItems( 42 | _ singleSectionItems: @escaping (Value, ResourceState) -> [ItemModelType] 43 | ) -> ItemModelsProducer where ItemModelType.E == E { 44 | 45 | let valueToListViewStateTransformer = 46 | ValueToListViewStateTransformer( 47 | valueToSingleSectionItems: { value, state in 48 | return singleSectionItems(value, state) 49 | } 50 | ) 51 | 52 | return ItemModelsProducer( 53 | baseValueToListViewStateTransformer: valueToListViewStateTransformer 54 | ) 55 | } 56 | 57 | public func listViewState(with state: ResourceState) 58 | -> ListViewState { 59 | 60 | return stateToListViewState(state, valueToListViewStateTransformer) 61 | } 62 | 63 | } 64 | 65 | public struct ValueToListViewStateTransformer 66 | { 67 | 68 | // We require Value to be passed besides the ResourceState, even though the 69 | // ResourceState will contain that same Value. We do this to make sure that 70 | // a Value is indeed available (compiletime safety). If ResourceState is 71 | // refactored to an enum (again) later, we can get rid of this. 72 | public typealias ValueToListViewState = (Value, ResourceState) 73 | -> ListViewState 74 | 75 | public let valueToListViewState: ValueToListViewState 76 | 77 | public init(_ valueToListViewState: @escaping ValueToListViewState) { 78 | self.valueToListViewState = valueToListViewState 79 | } 80 | 81 | public init( 82 | valueToSections: @escaping (Value, ResourceState) 83 | -> [SectionAndItems] 84 | ) { 85 | self.valueToListViewState = { value, resourceState in 86 | return ListViewState.readyToDisplay( 87 | resourceState, 88 | valueToSections(value, resourceState) 89 | ) 90 | } 91 | } 92 | 93 | } 94 | 95 | public extension ValueToListViewStateTransformer where SectionModelType == NoSection { 96 | 97 | init( 98 | valueToSingleSectionItems: @escaping (Value, ResourceState) -> [ItemModelType] 99 | ) { 100 | self.valueToListViewState = { value, state in 101 | let sectionAndItems = SectionAndItems( 102 | NoSection(), 103 | valueToSingleSectionItems(value, state) 104 | ) 105 | return ListViewState.readyToDisplay(state, [sectionAndItems]) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/ItemViewProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ItemViewProducer { 4 | associatedtype ItemModelType: Equatable 5 | associatedtype ProducedView: UIView 6 | associatedtype ContainingView: UIView 7 | func register(at containingView: ContainingView) 8 | func view(containingView: ContainingView, item: ItemModelType, for indexPath: IndexPath) -> ProducedView 9 | 10 | var defaultView: ProducedView { get } 11 | } 12 | 13 | public extension ItemViewProducer { 14 | var any: AnyItemViewProducer { 15 | return AnyItemViewProducer(self) 16 | } 17 | } 18 | 19 | public struct AnyItemViewProducer 20 | : ItemViewProducer { 21 | 22 | public typealias ItemModelType = ItemModelType_ 23 | public typealias ProducedView = ProducedView_ 24 | public typealias ContainingView = ContainingView_ 25 | 26 | private let _view: (ContainingView, ItemModelType, IndexPath) -> ProducedView 27 | private let _register: (ContainingView) -> Void 28 | 29 | public let defaultView: ProducedView 30 | 31 | public init(_ producer: P) where P.ItemModelType == ItemModelType, 32 | P.ProducedView == ProducedView, P.ContainingView == ContainingView { 33 | self._view = producer.view 34 | self._register = producer.register 35 | self.defaultView = producer.defaultView 36 | } 37 | 38 | public func view(containingView: ContainingView, item: ItemModelType, for indexPath: IndexPath) 39 | -> ProducedView { 40 | 41 | return _view(containingView, item, indexPath) 42 | } 43 | 44 | public func register(at containingView: ContainingView_) { 45 | _register(containingView) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/ItemViewsProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ItemViewsProducer { 4 | public typealias PreferredViewWidth = CGFloat 5 | 6 | public let produceView: (ItemModelType, ContainingView, IndexPath) -> ProducedView 7 | public let registerAtContainingView: (ContainingView) -> Void 8 | public let itemViewSize: ((ItemModelType, ContainingView) -> CGSize)? 9 | 10 | public init( 11 | produceView: @escaping (ItemModelType, ContainingView, IndexPath) -> ProducedView, 12 | registerAtContainingView: @escaping (ContainingView) -> Void, 13 | itemViewSize: ((ItemModelType, ContainingView) -> CGSize)? = nil 14 | ) { 15 | self.produceView = produceView 16 | self.registerAtContainingView = registerAtContainingView 17 | self.itemViewSize = itemViewSize 18 | } 19 | } 20 | 21 | public protocol MultiViewTypeItemModel: ItemModel { 22 | associatedtype ItemViewType: CaseIterable 23 | 24 | var itemViewType: ItemViewType { get } 25 | } 26 | 27 | public extension ItemViewsProducer where ItemModelType: MultiViewTypeItemModel { 28 | 29 | init( 30 | viewProducerForViewType: @escaping (ItemModelType.ItemViewType) -> ViewProducer, 31 | itemViewSize: ((ItemModelType, ContainingView) -> CGSize)? = nil 32 | ) where ViewProducer.ContainingView == ContainingView, 33 | ViewProducer.ItemModelType == ItemModelType, 34 | ViewProducer.ProducedView == ProducedView { 35 | 36 | self.produceView = { itemModel, containingView, indexPath -> ProducedView in 37 | let viewProducer = viewProducerForViewType(itemModel.itemViewType) 38 | return viewProducer.view(containingView: containingView, item: itemModel, for: indexPath) 39 | } 40 | self.registerAtContainingView = { containingView in 41 | ItemModelType.ItemViewType.allCases.forEach { 42 | let viewProducer = viewProducerForViewType($0) 43 | viewProducer.register(at: containingView) 44 | } 45 | } 46 | self.itemViewSize = itemViewSize 47 | } 48 | } 49 | 50 | public extension ItemViewsProducer { 51 | 52 | init(simpleWithViewProducer viewProducer: ViewProducer) 53 | where ViewProducer.ItemModelType == ItemModelType, ViewProducer.ContainingView == ContainingView, 54 | ViewProducer.ProducedView == ProducedView { 55 | 56 | self.init( 57 | produceView: { item, containingView, indexPath -> ProducedView in 58 | return viewProducer.view(containingView: containingView, item: item, for: indexPath) 59 | }, 60 | registerAtContainingView: { containingView in 61 | viewProducer.register(at: containingView) 62 | } 63 | ) 64 | } 65 | 66 | static var noSupplementaryTableViewAdapter: ItemViewsProducer 67 | { 68 | 69 | return ItemViewsProducer( 70 | produceView: { _, _, _ in UIView() }, 71 | registerAtContainingView: { _ in } 72 | ) 73 | } 74 | 75 | } 76 | 77 | extension ItemViewsProducer where ItemModelType == NoSupplementaryItemModel { 78 | 79 | static var noSupplementaryViewAdapter: ItemViewsProducer 80 | { 81 | 82 | return ItemViewsProducer( 83 | produceView: { _, _, _ in UIView() }, 84 | registerAtContainingView: { _ in } 85 | ) 86 | } 87 | } 88 | 89 | public typealias TableViewCellAdapter 90 | = ItemViewsProducer 91 | 92 | public extension TableViewCellAdapter { 93 | 94 | static func tableViewCell( 95 | withCellClass `class`: CellView.Type, 96 | reuseIdentifier: String, 97 | configure: @escaping (Cell, UITableViewCell) -> Void 98 | ) -> TableViewCellAdapter { 99 | 100 | return TableViewCellAdapter( 101 | simpleWithViewProducer: SimpleTableViewCellProducer.classAndIdentifier( 102 | class: `class`, 103 | identifier: reuseIdentifier, 104 | configure: configure 105 | ) 106 | ) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/SectionModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SectionModel: Equatable { 4 | 5 | // Required to display configuration or system messages or errors 6 | // (will probably contain only one descriptive cell) 7 | init() 8 | } 9 | 10 | /// Section implementation that has no data attached. Ideal 11 | /// for lists where all data resides in the cells. 12 | public struct PlainSectionModel : SectionModel { 13 | public init() {} 14 | } 15 | 16 | /// To be used when a list view shall have no _visible_ sections at all. 17 | /// This is mainly for matching generics such that a SingleSection*Controller 18 | /// can be created cleanly. 19 | public struct NoSection: SectionModel { 20 | public init() {} 21 | } 22 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/SupplementaryItemModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A supplementary item is the pendant of a section header 4 | /// of UITableView, or a supplementary view of UICollectionView. 5 | public protocol SupplementaryItemModel: Equatable { 6 | associatedtype E: ResourceError 7 | 8 | // Required to display configuration or system errors 9 | // for easier debugging. 10 | init(error: E) 11 | 12 | var type: SupplementaryItemModelType { get } 13 | } 14 | 15 | public enum SupplementaryItemModelType { 16 | case `default` 17 | case header 18 | case footer 19 | } 20 | 21 | public struct NoSupplementaryItemModel: SupplementaryItemModel { 22 | public typealias E = NoResourceError 23 | 24 | public init(error: E) {} 25 | 26 | public var type: SupplementaryItemModelType { 27 | return .default 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/List/SupplementaryItemModelProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol SupplementaryItemModelProducer { 4 | associatedtype SupplementaryItemModelType: SupplementaryItemModel 5 | associatedtype ProducedView: UIView 6 | associatedtype ContainingView: UIView 7 | func register(at containingView: ContainingView) 8 | func view(containingView: ContainingView, item: SupplementaryItemModelType, for indexPath: IndexPath) 9 | -> ProducedView 10 | 11 | var defaultView: ProducedView { get } 12 | } 13 | 14 | public extension SupplementaryItemModelProducer { 15 | var any: AnySupplementaryItemModelProducer { 16 | return AnySupplementaryItemModelProducer(self) 17 | } 18 | } 19 | 20 | public struct AnySupplementaryItemModelProducer 21 | 22 | : SupplementaryItemModelProducer { 23 | 24 | public typealias Item = Item_ 25 | public typealias ProducedView = ProducedView_ 26 | public typealias ContainingView = ContainingView_ 27 | 28 | private let _view: (ContainingView, Item, IndexPath) -> ProducedView 29 | private let _register: (ContainingView) -> Void 30 | 31 | public let defaultView: ProducedView 32 | 33 | public init(_ producer: P) where P.SupplementaryItemModelType == Item, 34 | P.ProducedView == ProducedView, P.ContainingView == ContainingView { 35 | self._view = producer.view 36 | self._register = producer.register 37 | self.defaultView = producer.defaultView 38 | } 39 | 40 | public func view(containingView: ContainingView, item: Item, for indexPath: IndexPath) -> ProducedView { 41 | return _view(containingView, item, indexPath) 42 | } 43 | 44 | public func register(at containingView: ContainingView_) { 45 | _register(containingView) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/Persister-Cache/CachePersister.swift: -------------------------------------------------------------------------------- 1 | import Cache 2 | import Foundation 3 | 4 | public struct CachePersister 5 | : ResourceStatePersister { 6 | public typealias Value = Value_ 7 | public typealias P = P_ 8 | public typealias E = E_ 9 | 10 | public typealias StatePersistenceKey = String 11 | 12 | private let key: StatePersistenceKey 13 | private let storage: Storage? 14 | 15 | public init(key: StatePersistenceKey, storage: Storage?) { 16 | self.key = key 17 | self.storage = storage 18 | } 19 | 20 | public init(key: StatePersistenceKey, diskConfig: DiskConfig? = nil, memoryConfig: MemoryConfig? = nil) { 21 | 22 | var fallbackDiskConfig: DiskConfig { 23 | return DiskConfig(name: key) 24 | } 25 | 26 | var fallbackMemoryConfig: MemoryConfig { 27 | return MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10) 28 | } 29 | 30 | var transformer: Transformer { 31 | return Transformer(toData: { state -> Data in 32 | return try JSONEncoder().encode(state) 33 | }, fromData: { data -> PersistedState in 34 | return try JSONDecoder().decode(ResourceState.self, from: data) 35 | }) 36 | } 37 | 38 | let storage = try? Storage(diskConfig: diskConfig ?? fallbackDiskConfig, 39 | memoryConfig: memoryConfig ?? fallbackMemoryConfig, 40 | transformer: transformer) 41 | 42 | self.init(key: key, storage: storage) 43 | } 44 | 45 | public func persist(_ state: PersistedState) { 46 | try? storage?.setObject(state, forKey: "latestValue") 47 | } 48 | 49 | public func load(_ parameters: P) -> PersistedState? { 50 | guard let storage = self.storage else { 51 | return nil 52 | } 53 | 54 | do { 55 | let state = try storage.object(forKey: "latestValue") 56 | if (state.loadImpulse?.params.isCacheCompatible(parameters) ?? false) { 57 | return state 58 | } else { 59 | return nil 60 | } 61 | } catch { 62 | return nil 63 | } 64 | } 65 | 66 | public func purge() { 67 | try? storage?.removeAll() 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /DataSourcerer/Classes/ReactiveSwift/ReactiveSwift.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ReactiveSwift 3 | import Result 4 | 5 | public extension Datasource { 6 | 7 | init( 8 | stateSignalProducer: @escaping (LoadImpulse

    ) -> SignalProducer, 9 | mapErrorString: @escaping (ErrorString) -> E, 10 | cacheBehavior: CacheBehavior, 11 | loadImpulseBehavior: LoadImpulseBehavior 12 | ) { 13 | 14 | let loadImpulseEmitter = loadImpulseBehavior.loadImpulseEmitter 15 | 16 | let states = ValueStream( 17 | signalProducer: stateSignalProducer, 18 | loadImpulseEmitter: loadImpulseEmitter.any 19 | ) 20 | .rememberLatestSuccessAndError( 21 | behavior: RememberLatestSuccessAndErrorBehavior( 22 | preferFallbackValueOverFallbackError: true 23 | ) 24 | ) 25 | 26 | let cachedStates = cacheBehavior 27 | .apply(on: states.any, 28 | loadImpulseEmitter: loadImpulseEmitter) 29 | .skipRepeats() 30 | .observeOnUIThread() 31 | 32 | let shareableCachedStates = cachedStates.shareable(initialValue: .notReady) 33 | 34 | self.init(shareableCachedStates, loadImpulseEmitter: loadImpulseEmitter) 35 | } 36 | 37 | /// `valueSignalAndLoadImpulseProducer` sends LoadImpulse

    besides the Value because 38 | /// the impulse or parameters can change while a request is being made, 39 | /// e.g. when a token which is part of parameters is refreshed. 40 | init( 41 | valueSignalAndLoadImpulseProducer: @escaping (LoadImpulse

    ) 42 | -> SignalProducer<(Value, LoadImpulse

    ), E>, 43 | mapErrorString: @escaping (ErrorString) -> E, 44 | cacheBehavior: CacheBehavior, 45 | loadImpulseBehavior: LoadImpulseBehavior, 46 | initialLoadingState: ((LoadImpulse

    ) -> ResourceState)? = nil 47 | ) { 48 | 49 | self.init( 50 | stateSignalProducer: { loadImpulse 51 | -> SignalProducer, NoError> in 52 | 53 | let initial = SignalProducer, NoError>( 54 | value: initialLoadingState?(loadImpulse) ?? ResourceState.loading( 55 | loadImpulse: loadImpulse, 56 | fallbackValueBox: nil, 57 | fallbackError: nil 58 | ) 59 | ) 60 | 61 | let producer = valueSignalAndLoadImpulseProducer(loadImpulse) 62 | let successOrError = producer 63 | .map { value, loadImpulse in 64 | return ResourceState 65 | .value( 66 | valueBox: EquatableBox(value), 67 | loadImpulse: loadImpulse, 68 | fallbackError: nil 69 | ) 70 | } 71 | .flatMapError { error -> SignalProducer, NoError> in 72 | return SignalProducer( 73 | value: ResourceState 74 | .error(error: error, loadImpulse: loadImpulse, fallbackValueBox: nil 75 | ) 76 | ) 77 | } 78 | return initial.concat(successOrError) 79 | }, 80 | mapErrorString: mapErrorString, 81 | cacheBehavior: cacheBehavior, 82 | loadImpulseBehavior: loadImpulseBehavior 83 | ) 84 | } 85 | 86 | } 87 | 88 | public extension ValueStream { 89 | 90 | init(signalProducer: SignalProducer) { 91 | 92 | self.init { sendValue, disposable in 93 | let reactiveSwiftDisposable = signalProducer.startWithValues { value in 94 | sendValue(value) 95 | } 96 | 97 | disposable += ActionDisposable { 98 | reactiveSwiftDisposable.dispose() 99 | } 100 | } 101 | } 102 | 103 | /// Initializes a ValueStream with a ReactiveSwift.SignalProducer. 104 | init( 105 | signalProducer: @escaping (LoadImpulse

    ) -> SignalProducer, 106 | loadImpulseEmitter: AnyLoadImpulseEmitter

    107 | ) where ObservedValue == ResourceState { 108 | 109 | self.init { sendState, disposable in 110 | 111 | disposable += loadImpulseEmitter 112 | .flatMapLatest { loadImpulse -> AnyObservable in 113 | return ValueStream(signalProducer: signalProducer(loadImpulse)).any 114 | } 115 | .observe { state in 116 | sendState(state) 117 | } 118 | } 119 | } 120 | 121 | } 122 | 123 | public extension ShareableValueStream { 124 | 125 | /// Initializes a ValueStream with a ReactiveSwift.SignalProducer. 126 | var reactiveSwiftProperty: ReactiveSwift.Property { 127 | 128 | let signalProducer = SignalProducer { observer, lifetime in 129 | 130 | let dataSourcererDisposable = self.skip(first: 1) 131 | .observe { value in 132 | observer.send(value: value) 133 | } 134 | 135 | lifetime += ReactiveSwift.AnyDisposable { 136 | dataSourcererDisposable.dispose() 137 | } 138 | } 139 | 140 | return ReactiveSwift.Property(initial: self.value, then: signalProducer) 141 | } 142 | 143 | } 144 | 145 | public extension AnyObservable { 146 | 147 | var reactiveSwiftSignalProducer: ReactiveSwift.SignalProducer { 148 | 149 | return SignalProducer { observer, lifetime in 150 | 151 | let dataSourcererDisposable = self.observe { loadImpulse in 152 | observer.send(value: loadImpulse) 153 | } 154 | 155 | lifetime += ReactiveSwift.AnyDisposable { 156 | dataSourcererDisposable.dispose() 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Example/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - colon 3 | - comma 4 | - control_statement 5 | opt_in_rules: # some rules are only opt-in 6 | - anyobject_protocol 7 | - array_init 8 | - attributes 9 | - closure_end_indentation 10 | - closure_spacing 11 | - collection_alignment 12 | - contains_over_first_not_nil 13 | - empty_string 14 | - empty_xctest_method 15 | - explicit_init 16 | - extension_access_modifier 17 | - fallthrough 18 | - fatal_error_message 19 | - file_header 20 | - first_where 21 | - identical_operands 22 | - joined_default_parameter 23 | - let_var_whitespace 24 | - last_where 25 | - literal_expression_end_indentation 26 | - lower_acl_than_parent 27 | - nimble_operator 28 | - number_separator 29 | - object_literal 30 | - operator_usage_whitespace 31 | - overridden_super_call 32 | - override_in_extension 33 | - pattern_matching_keywords 34 | - private_action 35 | - private_outlet 36 | - prohibited_interface_builder 37 | - prohibited_super_call 38 | - quick_discouraged_call 39 | - quick_discouraged_focused_test 40 | - quick_discouraged_pending_test 41 | - redundant_nil_coalescing 42 | - redundant_type_annotation 43 | - single_test_class 44 | - sorted_first_last 45 | - sorted_imports 46 | - static_operator 47 | - unavailable_function 48 | - unneeded_parentheses_in_closure_argument 49 | - untyped_error_in_catch 50 | - vertical_parameter_alignment_on_call 51 | - xct_specific_matcher 52 | - yoda_condition 53 | included: # paths to include during linting. `--path` is ignored if present. 54 | - ../DataSourcerer/Classes/ 55 | - DataSourcerer 56 | excluded: # paths to ignore during linting. Takes precedence over `included`. 57 | - Pods 58 | 59 | # configurable rules can be customized from this configuration file 60 | # binary rules can set their severity level 61 | force_cast: warning # implicitly 62 | force_try: 63 | severity: warning # explicitly 64 | # rules that have both warning and error levels, can set just the warning level 65 | # implicitly 66 | line_length: 110 67 | # they can set both implicitly with an array 68 | type_body_length: 69 | - 300 # warning 70 | - 400 # error 71 | # or they can set both explicitly 72 | file_length: 73 | warning: 500 74 | error: 1200 75 | # naming rules can set warnings/errors for min_length and max_length 76 | # additionally they can set excluded names 77 | type_name: 78 | min_length: 4 # only warning 79 | max_length: # warning and error 80 | warning: 44 81 | error: 50 82 | excluded: 83 | - iPhone 84 | - P 85 | - E 86 | - LI 87 | - T 88 | allowed_symbols: "_" 89 | generic_type_name: 90 | allowed_symbols: "_" 91 | identifier_name: 92 | min_length: # only min_length 93 | error: 4 # only error 94 | excluded: # excluded via string array 95 | - id 96 | - URL 97 | - GlobalAPIKey 98 | - any 99 | - key 100 | - lhs 101 | - rhs 102 | - bag 103 | - box 104 | - nib 105 | - url 106 | - if 107 | - row 108 | - map 109 | allowed_symbols: "_" 110 | nesting: 111 | type_level: 2 112 | cyclomatic_complexity: 113 | warning: 12 114 | error: 20 115 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji, sonarqube, markdown) -------------------------------------------------------------------------------- /Example/BACKLOG.md: -------------------------------------------------------------------------------- 1 | # Datasourcerer Backlog 2 | 3 | - Make ResourceState an enum again? 4 | - Most things called "singleSection" can be deleted now? 5 | - Add builder pattern for Datasource? Expose only one method per step. 6 | - Rename State > ListState 7 | - Make Datasource operate only on States? 8 | - Improve or remove StateErrorMessage from APIError (seems clunky) 9 | - Internationalize error messages 10 | - Find better name for errorMaker 11 | - Add SwiftFormat by Nick Lockwood 12 | - Split Idiomatic* into BaseItem, LoadableItem, FailableItem, EmptyableItem 13 | - Remove Error from ItemModel (put into FailableItem?) 14 | - Make initializer for ListCore without headers and footers (NoSupplementaryItemModel..), make those configurable 15 | - Rename Parameters protocol (a bit opaque for lib users) 16 | 17 | -------------------------------------------------------------------------------- /Example/DataSourcerer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/DataSourcerer.xcodeproj/xcshareddata/xcschemes/DataSourcerer-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | 68 | 78 | 80 | 86 | 87 | 88 | 89 | 90 | 91 | 97 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Example/DataSourcerer.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/DataSourcerer.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/DataSourcerer/APIError.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | 4 | enum APIError : IdiomaticStateError, Codable { 5 | case unknown(StateErrorMessage) 6 | case unreachable 7 | case notConnectedToInternet 8 | case deserializationFailed(path: String?, debugDescription: String?, responseSize: Int) 9 | case requestTagsChanged 10 | case notAuthenticated 11 | case cacheCouldNotLoad(StateErrorMessage) 12 | 13 | var errorMessage: StateErrorMessage { 14 | let defaultMessage = StateErrorMessage.message( 15 | NSLocalizedString("An error occurred while loading.\nPlease try again!", comment: "") 16 | ) 17 | 18 | switch self { 19 | case let .unknown(message): 20 | return message 21 | case .unreachable: 22 | return defaultMessage 23 | case .deserializationFailed: 24 | return .message(NSLocalizedString(""" 25 | An error occurred while deserialization. 26 | Please contact us! 27 | """, comment: "")) 28 | case .requestTagsChanged: 29 | return .message(NSLocalizedString("No data could be loaded for your user.", comment: "")) 30 | case .notAuthenticated: 31 | return .message(NSLocalizedString("Please log in.", comment: "")) 32 | case .notConnectedToInternet: 33 | return .message(NSLocalizedString("Please connect to the internet.", comment: "")) 34 | case let .cacheCouldNotLoad(errorMessage): 35 | return errorMessage 36 | } 37 | } 38 | 39 | init(message: StateErrorMessage) { 40 | self = .unknown(message) 41 | } 42 | } 43 | 44 | extension APIError { 45 | 46 | enum CodingKeys: String, CodingKey { 47 | case enumCaseKey = "type" 48 | case deserializationFailed 49 | case deserializationFailedPath 50 | case deserializationFailedDebugDescription 51 | case deserializationFailedResponseSize 52 | case unknown 53 | case unknownDescription 54 | case requestTagsChanged 55 | case unreachable 56 | case notAuthenticated 57 | case notConnectedToInternet 58 | case cacheCouldNotLoad 59 | case cacheCouldNotLoadType 60 | } 61 | 62 | internal init(from decoder: Decoder) throws { 63 | let container = try decoder.container(keyedBy: CodingKeys.self) 64 | 65 | let enumCaseString = try container.decode(String.self, forKey: .enumCaseKey) 66 | guard let enumCase = CodingKeys(rawValue: enumCaseString) else { 67 | throw DecodingError.dataCorrupted( 68 | .init(codingPath: decoder.codingPath, 69 | debugDescription: "Unknown enum case '\(enumCaseString)'") 70 | ) 71 | } 72 | 73 | switch enumCase { 74 | case .deserializationFailed: 75 | let path = try? container.decode(String.self, forKey: .deserializationFailedPath) 76 | let debugDescription = try? container.decode(String.self, 77 | forKey: .deserializationFailedDebugDescription) 78 | let responseSize = try? container.decode(Int.self, forKey: .deserializationFailedResponseSize) 79 | self = .deserializationFailed(path: (path ?? "no path available"), 80 | debugDescription: debugDescription, 81 | responseSize: responseSize ?? 0) 82 | case .unknown: 83 | let unknownDecription = try? container.decode(String.self, forKey: .unknownDescription) 84 | self = .unknown(unknownDecription.map { StateErrorMessage.message($0) } ?? .default) 85 | case .notAuthenticated: 86 | self = .notAuthenticated 87 | case .requestTagsChanged: 88 | self = .requestTagsChanged 89 | case .unreachable: 90 | self = .unreachable 91 | case .notConnectedToInternet: 92 | self = .notConnectedToInternet 93 | case .cacheCouldNotLoad: 94 | let type = try? container.decode(StateErrorMessage.self, forKey: .cacheCouldNotLoadType) 95 | self = .cacheCouldNotLoad(type ?? .default) 96 | default: throw DecodingError.dataCorrupted( 97 | .init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case '\(enumCase)'") 98 | ) 99 | } 100 | } 101 | 102 | internal func encode(to encoder: Encoder) throws { 103 | var container = encoder.container(keyedBy: CodingKeys.self) 104 | 105 | switch self { 106 | case let .deserializationFailed(path, debugDescription, responseSize): 107 | try container.encode(CodingKeys.deserializationFailed.rawValue, forKey: .enumCaseKey) 108 | if let path = path { 109 | try container.encode(path, forKey: .deserializationFailedPath) 110 | } 111 | if let debugDescription = debugDescription { 112 | try container.encode(debugDescription, forKey: .deserializationFailedDebugDescription) 113 | } 114 | try container.encode(responseSize, forKey: .deserializationFailedResponseSize) 115 | case let .unknown(description): 116 | try container.encode(CodingKeys.unknown.rawValue, forKey: .enumCaseKey) 117 | try container.encode(description, forKey: .unknownDescription) 118 | case .requestTagsChanged: 119 | try container.encode(CodingKeys.requestTagsChanged.rawValue, forKey: .enumCaseKey) 120 | case .unreachable: 121 | try container.encode(CodingKeys.unreachable.rawValue, forKey: .enumCaseKey) 122 | case .notAuthenticated: 123 | try container.encode(CodingKeys.notAuthenticated.rawValue, forKey: .enumCaseKey) 124 | case .notConnectedToInternet: 125 | try container.encode(CodingKeys.notConnectedToInternet.rawValue, forKey: .enumCaseKey) 126 | case let .cacheCouldNotLoad(type): 127 | try container.encode(CodingKeys.cacheCouldNotLoad.rawValue, forKey: .enumCaseKey) 128 | try container.encode(type, forKey: .cacheCouldNotLoadType) 129 | } 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /Example/DataSourcerer/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | var mainNavigationController: UINavigationController? { 9 | return window!.rootViewController as? UINavigationController 10 | } 11 | 12 | func application(_ application: UIApplication, 13 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) 14 | -> Bool { 15 | 16 | // Skip app launch if testing 17 | guard ProcessInfo.processInfo.environment["XCInjectBundleInto"] == nil else { 18 | return false 19 | } 20 | 21 | NotificationCenter.default.post(name: Notification.Name("IBARevealRequestStart"), object: nil) 22 | // window = UIWindow(frame: UIScreen.main.bounds) 23 | // if let window = window { 24 | // window.rootViewController = 25 | // UINavigationController(rootViewController: PublicReposRootViewController()) 26 | // window.makeKeyAndVisible() 27 | // } 28 | 29 | return true 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Example/DataSourcerer/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | enum ChatBotCell : MultiViewTypeItemModel { 5 | typealias ItemViewType = ViewType 6 | typealias E = APIError 7 | 8 | case message(ChatBotMessage) 9 | case header(String) 10 | case error(APIError) 11 | case oldMessagesLoading // loading indicator 12 | 13 | init(error: APIError) { 14 | self = .error(error) 15 | } 16 | 17 | var itemViewType: ChatBotCell.ViewType { 18 | switch self { 19 | case .message, .error, .header: 20 | return .message 21 | case .oldMessagesLoading: 22 | return .loadOldMessages 23 | } 24 | } 25 | 26 | enum ViewType: Int, Equatable, CaseIterable { 27 | case message 28 | case loadOldMessages 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotIncomingMessageTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class ChatBotIncomingMessageTableViewCell : UITableViewCell { 5 | 6 | lazy var bubbleImageView: UIImageView = { 7 | let view = UIImageView(image: UIImage(named: "chat_bubble_incoming")?.resizableImage( 8 | withCapInsets: UIEdgeInsets(top: 15, left: 25, bottom: 15, right: 15), 9 | resizingMode: .stretch 10 | ) 11 | ) 12 | 13 | self.contentView.addSubview(view) 14 | 15 | view.translatesAutoresizingMaskIntoConstraints = false 16 | view.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).isActive = true 17 | view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true 18 | view.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 20).isActive = true 19 | view.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.4).isActive = true 20 | 21 | return view 22 | }() 23 | 24 | lazy var messageLabel: UILabel = { 25 | let label = UILabel() 26 | label.textColor = .white 27 | label.numberOfLines = 0 28 | 29 | self.contentView.addSubview(label) 30 | 31 | label.translatesAutoresizingMaskIntoConstraints = false 32 | label.topAnchor.constraint(equalTo: bubbleImageView.topAnchor, constant: 15).isActive = true 33 | label.bottomAnchor.constraint(equalTo: bubbleImageView.bottomAnchor, constant: -15).isActive = true 34 | label.leftAnchor.constraint(equalTo: bubbleImageView.leftAnchor, constant: 25).isActive = true 35 | label.rightAnchor.constraint(equalTo: bubbleImageView.rightAnchor, constant: -15).isActive = true 36 | 37 | return label 38 | }() 39 | 40 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 41 | super.init(style: style, reuseIdentifier: reuseIdentifier) 42 | commonInit() 43 | } 44 | 45 | required init?(coder aDecoder: NSCoder) { 46 | super.init(coder: aDecoder) 47 | commonInit() 48 | } 49 | 50 | private func commonInit() { 51 | _ = [bubbleImageView, messageLabel] // force init order 52 | self.backgroundColor = .clear 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotItemViewsProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct ChatBotItemViewsProducer { 5 | 6 | func make() -> ItemViewsProducer { 7 | 8 | return ItemViewsProducer( 9 | viewProducerForViewType: { viewType -> SimpleTableViewCellProducer in 10 | switch viewType { 11 | case .message: 12 | return SimpleTableViewCellProducer.classAndIdentifier( 13 | class: ChatBotIncomingMessageTableViewCell.self, 14 | identifier: "messageCell", 15 | configure: { cell, cellView in 16 | (cellView as? ChatBotIncomingMessageTableViewCell)?.messageLabel.text = { 17 | switch cell { 18 | case let .message(message): return message.message 19 | case let .header(title): return title 20 | case .error, .oldMessagesLoading: return nil 21 | } 22 | }() 23 | } 24 | ) 25 | case .loadOldMessages: 26 | return SimpleTableViewCellProducer.classAndIdentifier( 27 | class: LoadingCell.self, 28 | identifier: "loadingCell", 29 | configure: { _, cellView in 30 | cellView.backgroundColor = .clear 31 | (cellView as? LoadingCell)?.loadingIndicatorView.color = .white 32 | } 33 | ) 34 | } 35 | } 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotMockStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | class ChatBotMockStorage { 5 | var messages: [ChatBotMessage] = [] 6 | 7 | func loadInitial(limit: Int, completion: @escaping (ChatBotResponse) -> Void) { 8 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in 9 | guard let self = self else { return } 10 | let newOldestMessages = self.makeOldestMessages(limit: limit) 11 | self.messages = newOldestMessages 12 | completion(ChatBotResponse(messages: newOldestMessages, currentAvailableActions: [])) 13 | } 14 | } 15 | 16 | func loadMoreOldMessages(limit: Int, completion: @escaping (ChatBotResponse) -> Void) { 17 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in 18 | guard let self = self else { return } 19 | let newOldestMessages = self.makeOldestMessages(limit: limit) 20 | self.messages = newOldestMessages + self.messages 21 | completion(ChatBotResponse(messages: newOldestMessages, currentAvailableActions: [])) 22 | } 23 | } 24 | 25 | func loadNewMessage(completion: @escaping (ChatBotResponse) -> Void) { 26 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in 27 | guard let self = self else { return } 28 | let newMessage = self.makeNewMessage() 29 | self.messages += [newMessage] 30 | completion(ChatBotResponse(messages: [newMessage], currentAvailableActions: [])) 31 | } 32 | } 33 | 34 | func makeOldestMessages(limit: Int) -> [ChatBotMessage] { 35 | var oldestMessage = self.messages.first 36 | var oldestMessages = [ChatBotMessage]() 37 | (1...limit).forEach { _ in 38 | let message = ChatBotMessage(oldMessageWithCurrentOldestMessage: oldestMessage) 39 | oldestMessages.insert(message, at: 0) 40 | oldestMessage = message 41 | } 42 | return oldestMessages 43 | } 44 | 45 | func makeNewMessage() -> ChatBotMessage { 46 | return ChatBotMessage(newMessageWithCurrentNewestMessage: self.messages.last) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct InitialChatBotRequest: ResourceParams, Equatable { 5 | let limit: Int 6 | 7 | func isCacheCompatible(_ candidate: InitialChatBotRequest) -> Bool { 8 | // In a real-world scenario, you would want to return true if 9 | // self belongs to the same message list like candidate 10 | // (e.g. same user is authenticated, and message list id is 11 | // equal). 12 | return true 13 | } 14 | } 15 | 16 | struct OldMessagesChatBotRequest: ResourceParams, Equatable { 17 | let oldestKnownMessageId: String 18 | let limit: Int 19 | 20 | func isCacheCompatible(_ candidate: OldMessagesChatBotRequest) -> Bool { 21 | // In a real-world scenario, you would want to return true if 22 | // self belongs to the same message list like candidate 23 | // (e.g. same user is authenticated, and message list id is 24 | // equal). 25 | return true 26 | } 27 | } 28 | 29 | struct NewMessagesChatBotRequest: ResourceParams, Equatable { 30 | let newestKnownMessageId: String 31 | 32 | func isCacheCompatible(_ candidate: NewMessagesChatBotRequest) -> Bool { 33 | // In a real-world scenario, you would want to return true if 34 | // self belongs to the same message list like candidate 35 | // (e.g. same user is authenticated, and message list id is 36 | // equal). 37 | return true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct ChatBotResponse: Equatable, Codable { 5 | let messages: [ChatBotMessage] 6 | let currentAvailableActions: [ChatBotAction] 7 | } 8 | 9 | struct ChatBotMessage: Equatable, Codable { 10 | let senderIsMe: Bool 11 | let message: String 12 | let sentAt: Date 13 | } 14 | 15 | extension ChatBotMessage { 16 | init(oldMessageWithCurrentOldestMessage oldestMessage: ChatBotMessage?) { 17 | let message: String = { 18 | guard let components = oldestMessage?.message.split(separator: " "), 19 | components.count == 2 else { return "Message 1000" } 20 | return components[0] + " " + String((Int(components[1]) ?? 1_000) - 1) 21 | }() 22 | self.init( 23 | senderIsMe: false, 24 | message: message, 25 | sentAt: oldestMessage?.sentAt.addingTimeInterval(-60) ?? Date() 26 | ) 27 | } 28 | 29 | init(newMessageWithCurrentNewestMessage newestMessage: ChatBotMessage?) { 30 | let message: String = { 31 | guard let components = newestMessage?.message.split(separator: " "), 32 | components.count == 2 else { return "Message 1000" } 33 | return components[0] + " " + String((Int(components[1]) ?? 1_000) + 1) 34 | }() 35 | self.init( 36 | senderIsMe: false, 37 | message: message, 38 | sentAt: Date() 39 | ) 40 | } 41 | } 42 | 43 | enum ChatBotAction: String, Equatable, Codable { 44 | case accept 45 | } 46 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotTableItemModelsProducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct ChatBotTableItemModelsProducer { 5 | 6 | func make() 7 | -> ItemModelsProducer 8 | { 9 | 10 | return ItemModelsProducer(baseValueToListViewStateTransformer: valueToListViewStateTransformer()) 11 | } 12 | 13 | private func valueToListViewStateTransformer() 14 | -> ValueToListViewStateTransformer 15 | { 16 | return ValueToListViewStateTransformer { value, resourceState 17 | -> ListViewState 18 | in 19 | 20 | let initialRequestProvisioningState = resourceState.provisioningState 21 | switch initialRequestProvisioningState { 22 | case .notReady: 23 | return .notReady 24 | case .loading, .result: 25 | let oldMessages = value.oldMessagesState.value?.value.messages ?? [] 26 | let initialMessages = value.initialLoadResponse.messages 27 | let newMessages = value.newMessagesState.value?.value.messages ?? [] 28 | let allMessages = oldMessages + initialMessages + newMessages 29 | var allCells = allMessages.map { ChatBotCell.message($0) } 30 | 31 | // Add loading old messages cell if result is shown. 32 | // Later on, hide loading cell if no more old messages available. 33 | if case .result = initialRequestProvisioningState { 34 | allCells = [ChatBotCell.oldMessagesLoading] + allCells 35 | } 36 | 37 | let sectionAndItems = SectionAndItems(NoSection(), allCells) 38 | return ListViewState.readyToDisplay(resourceState, [sectionAndItems]) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotTableViewController.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | import UIKit 4 | 5 | class ChatBotTableViewController : UIViewController { 6 | 7 | let viewModel = ChatBotTableViewModel() 8 | 9 | lazy var watchdog = Watchdog(threshold: 0.1, strictMode: false) 10 | 11 | private let disposeBag = DisposeBag() 12 | private lazy var cellUpdateInterceptor = ChatBotTableCellUpdateInterceptor() 13 | private var loadOldMessagesTimer: Timer? 14 | 15 | private lazy var tableViewController = 16 | ListViewDatasourceConfiguration 17 | .buildSingleSectionTableView( 18 | datasource: viewModel.datasource, 19 | withCellModelType: ChatBotCell.self 20 | ) 21 | .setItemModelsProducer( 22 | ChatBotTableItemModelsProducer().make() 23 | ) 24 | .setItemViewsProducer( 25 | ChatBotItemViewsProducer().make() 26 | ) 27 | .configurationForFurtherCustomization 28 | .showLoadingAndErrorStates( 29 | behavior: ShowLoadingAndErrorsBehavior( 30 | errorsBehavior: .preferFallbackValueOverError 31 | ), 32 | noResultsText: "You have received no messages so far.", 33 | loadingViewProducer: SimpleTableViewCellProducer.instantiate { _ in 34 | let loadingCell = LoadingCell() 35 | loadingCell.loadingIndicatorView.color = .white 36 | loadingCell.backgroundColor = .clear 37 | return loadingCell 38 | }, 39 | errorViewProducer: SimpleTableViewCellProducer.instantiate { cell in 40 | guard case let .error(error) = cell else { return ErrorTableViewCell() } 41 | let tableViewCell = ErrorTableViewCell() 42 | tableViewCell.content = error.errorMessage 43 | return tableViewCell 44 | }, 45 | noResultsViewProducer: SimpleTableViewCellProducer.instantiate { _ in 46 | let tableViewCell = ErrorTableViewCell() 47 | tableViewCell.content = StateErrorMessage 48 | .message("You have received no messages so far.") 49 | return tableViewCell 50 | } 51 | ) 52 | .singleSectionTableViewController 53 | 54 | override func viewDidLoad() { 55 | super.viewDidLoad() 56 | 57 | title = "Chatbot TableView" 58 | tableViewController.supportPullToRefresh = false 59 | 60 | tableViewController.willMove(toParent: self) 61 | self.addChild(tableViewController) 62 | self.view.addSubview(tableViewController.view) 63 | 64 | let view = tableViewController.view! 65 | view.translatesAutoresizingMaskIntoConstraints = false 66 | view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true 67 | view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 68 | view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true 69 | view.rightAnchor.constraint(equalTo: self.view.rightAnchor).isActive = true 70 | 71 | tableViewController.didMove(toParent: self) 72 | tableViewController.willChangeCellsInView = { [weak self] tableView, previous, next in 73 | self?.cellUpdateInterceptor.willChangeCells( 74 | tableView: tableView, 75 | previous: previous, 76 | next: next 77 | ) 78 | return 79 | } 80 | 81 | tableViewController.didChangeCellsInView = { [weak self] tableView, previous, next in 82 | self?.cellUpdateInterceptor.didChangeCells( 83 | tableView: tableView, 84 | previous: previous, 85 | next: next 86 | ) 87 | return 88 | } 89 | 90 | tableViewController.tableView.backgroundColor = UIColor(red: 0, green: 0.6, blue: 0.91, alpha: 1) 91 | tableViewController.tableView.separatorStyle = .none 92 | } 93 | 94 | override func viewWillAppear(_ animated: Bool) { 95 | super.viewWillAppear(animated) 96 | 97 | viewModel.startReceivingNewMessages() 98 | loadOldMessagesTimer?.invalidate() 99 | loadOldMessagesTimer = Timer.scheduledTimer( 100 | withTimeInterval: 0.3, 101 | repeats: true, 102 | block: { [weak self] _ in 103 | guard let tableView = self?.tableViewController.tableView else { return } 104 | self?.viewModel.tryLoadOldMessages(tableView: tableView) 105 | } 106 | ) 107 | } 108 | 109 | override func viewDidAppear(_ animated: Bool) { 110 | super.viewDidAppear(animated) 111 | 112 | _ = watchdog // init 113 | } 114 | 115 | override func viewWillDisappear(_ animated: Bool) { 116 | super.viewWillDisappear(animated) 117 | 118 | viewModel.stopReceivingNewMessages() 119 | loadOldMessagesTimer?.invalidate() 120 | } 121 | 122 | func repoSelected(repo: PublicRepo) { 123 | print("Repo selected") 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ChatBotTableViewModel.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | 4 | class ChatBotTableViewModel { 5 | 6 | lazy var storage = ChatBotMockStorage() 7 | lazy var initialStates = InitialChatBotStates(storage: storage) 8 | lazy var newMessagesStates = NewMessagesChatBotStates(storage: storage) 9 | lazy var oldMessagesStates = OldMessagesChatBotStates(storage: storage) 10 | 11 | lazy var datasource: Datasource 12 | = { 13 | 14 | let resourceStates = self.initialStates.states() 15 | .flatMapLatest { [weak self] initialLoadState -> AnyObservable in 16 | guard let self = self else { return BroadcastObservable().any } 17 | 18 | let newMessages = self.newMessagesStates 19 | .states() 20 | .startWith(value: ResourceState.notReady) 21 | 22 | let oldMessages = self.oldMessagesStates 23 | .states() 24 | .startWith(value: ResourceState.notReady) 25 | 26 | return newMessages 27 | .startWith(value: ResourceState.notReady) 28 | .combine(with: oldMessages) 29 | .map { newMessagesLoadState, oldMessagesLoadState -> ChatBotResourceState in 30 | 31 | if let initialChatBotResponse = initialLoadState.value?.value { 32 | let postInitialLoadState = PostInitialLoadChatBotState( 33 | initialLoadResponse: initialChatBotResponse, 34 | newMessagesState: newMessagesLoadState, 35 | oldMessagesState: oldMessagesLoadState 36 | ) 37 | 38 | return ChatBotResourceState( 39 | provisioningState: initialLoadState.provisioningState, 40 | loadImpulse: initialLoadState.loadImpulse, 41 | value: EquatableBox(postInitialLoadState), 42 | error: initialLoadState.error 43 | ) 44 | } else { 45 | return ChatBotResourceState( 46 | provisioningState: initialLoadState.provisioningState, 47 | loadImpulse: initialLoadState.loadImpulse, 48 | value: nil, 49 | error: initialLoadState.error 50 | ) 51 | } 52 | } 53 | .startWith( 54 | value: ChatBotResourceState( 55 | provisioningState: initialLoadState.provisioningState, 56 | loadImpulse: initialLoadState.loadImpulse, 57 | value: (initialLoadState.value?.value).map { 58 | EquatableBox( 59 | PostInitialLoadChatBotState( 60 | initialLoadResponse: $0, 61 | newMessagesState: .notReady, 62 | oldMessagesState: .notReady 63 | ) 64 | ) 65 | }, 66 | error: initialLoadState.error 67 | ) 68 | ) 69 | } 70 | 71 | let cachedStates = Datasource.CacheBehavior.none 72 | .apply( 73 | on: resourceStates, 74 | loadImpulseEmitter: initialStates.loadImpulseEmitter.any 75 | ) 76 | .skipRepeats() 77 | .observeOnUIThread() 78 | 79 | let shareableCachedState = cachedStates 80 | .shareable(initialValue: .notReady) 81 | 82 | return Datasource( 83 | shareableCachedState, 84 | loadImpulseEmitter: initialStates.loadImpulseEmitter.any 85 | ) 86 | }() 87 | 88 | var newMessageTimer: Timer? 89 | 90 | func startReceivingNewMessages() { 91 | newMessageTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in 92 | guard let loadImpulseEmitter = self?.newMessagesStates.loadImpulseEmitter else { return } 93 | 94 | let loadImpulse = LoadImpulse( 95 | params: NewMessagesChatBotRequest(newestKnownMessageId: ""), 96 | type: LoadImpulseType(mode: LoadImpulseType.Mode.partialLoad, issuer: .system) 97 | ) 98 | loadImpulseEmitter.emit(loadImpulse: loadImpulse, on: .current) 99 | }) 100 | } 101 | 102 | func stopReceivingNewMessages() { 103 | newMessageTimer?.invalidate() 104 | } 105 | 106 | func tryLoadOldMessages(tableView: UITableView) { 107 | 108 | guard tableView.contentOffset.y < 100 else { 109 | return 110 | } 111 | 112 | guard let oldMessagesProvisioningState = 113 | datasource.state.value.value?.value.oldMessagesState.provisioningState else { 114 | return 115 | } 116 | 117 | switch oldMessagesProvisioningState { 118 | case .loading: 119 | return 120 | case .result, .notReady: 121 | break // continue 122 | } 123 | 124 | let request = OldMessagesChatBotRequest(oldestKnownMessageId: "mock value", limit: 20) 125 | let loadImpulse = LoadImpulse( 126 | params: request, 127 | type: LoadImpulseType( 128 | mode: .partialLoad, issuer: .user 129 | ) 130 | ) 131 | oldMessagesStates.loadImpulseEmitter.emit(loadImpulse: loadImpulse, on: .current) 132 | } 133 | 134 | } 135 | 136 | struct PostInitialLoadChatBotState: Equatable { 137 | let initialLoadResponse: InitialChatBotResponse 138 | let newMessagesState: ResourceState 139 | let oldMessagesState: ResourceState 140 | } 141 | 142 | typealias InitialChatBotResponse = ChatBotResponse // for better clarity 143 | 144 | typealias InitialChatBotError = APIError // for better clarity 145 | 146 | typealias ChatBotResourceState = ResourceState 147 | 148 | 149 | typealias ChatBotListViewState = SingleSectionListViewState 150 | > 151 | -------------------------------------------------------------------------------- /Example/DataSourcerer/ErrorTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | import UIKit 4 | 5 | public class ErrorTableViewCell : UITableViewCell { 6 | 7 | public var content: StateErrorMessage = .default { 8 | didSet { 9 | refreshContent() 10 | } 11 | } 12 | 13 | public var defaultErrorMessage: String { 14 | return NSLocalizedString(""" 15 | Ein Fehler ist beim Laden aufgetreten.\n 16 | Bitte versuchen Sie es erneut! 17 | """, comment: "") 18 | } 19 | 20 | public lazy var label: UILabel = { 21 | let label = UILabel() 22 | label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) 23 | label.textColor = .gray 24 | label.textAlignment = .center 25 | label.numberOfLines = 0 26 | 27 | self.contentView.addSubview(label) 28 | label.translatesAutoresizingMaskIntoConstraints = false 29 | label.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 20).isActive = true 30 | label.leftAnchor.constraint(equalTo: self.contentView.leftAnchor, constant: 20).isActive = true 31 | label.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -20).isActive = true 32 | label.rightAnchor.constraint(equalTo: self.contentView.rightAnchor, constant: -20).isActive = true 33 | label.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true 34 | 35 | return label 36 | }() 37 | 38 | public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 39 | super.init(style: style, reuseIdentifier: reuseIdentifier) 40 | commonInit() 41 | } 42 | 43 | public init() { 44 | super.init(style: .default, reuseIdentifier: nil) 45 | commonInit() 46 | } 47 | 48 | @available(*, unavailable) 49 | public required init?(coder aDecoder: NSCoder) { 50 | fatalError("ErrorTableViewCell cannot be used from a storyboard") 51 | } 52 | 53 | override public func willMove(toSuperview newSuperview: UIView?) { 54 | super.willMove(toSuperview: newSuperview) 55 | refreshContent() 56 | } 57 | 58 | func refreshContent() { 59 | label.text = { 60 | switch content { 61 | case .default: 62 | return defaultErrorMessage 63 | case let .message(string): 64 | return string 65 | } 66 | }() 67 | } 68 | 69 | public func commonInit() { 70 | backgroundColor = .white 71 | separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 9_999) 72 | selectionStyle = .none 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "size" : "1024x1024", 46 | "scale" : "1x" 47 | } 48 | ], 49 | "info" : { 50 | "version" : 1, 51 | "author" : "xcode" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "chat_bubble_incoming.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "chat_bubble_incoming@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "chat_bubble_incoming@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativepragmatics/DataSourcerer/eee12402b3d14f9da9f63bb17f09feee9fc11f9d/Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming.png -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativepragmatics/DataSourcerer/eee12402b3d14f9da9f63bb17f09feee9fc11f9d/Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming@2x.png -------------------------------------------------------------------------------- /Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/creativepragmatics/DataSourcerer/eee12402b3d14f9da9f63bb17f09feee9fc11f9d/Example/DataSourcerer/Images.xcassets/chat_bubble_incoming.imageset/chat_bubble_incoming@3x.png -------------------------------------------------------------------------------- /Example/DataSourcerer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Example/DataSourcerer/InitialChatBotStates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct InitialChatBotStates { 5 | 6 | let storage: ChatBotMockStorage 7 | let loadImpulseEmitter = SimpleLoadImpulseEmitter( 8 | initialImpulse: LoadImpulse(params: InitialChatBotRequest(limit: 20), type: .initial) 9 | ) 10 | 11 | init(storage: ChatBotMockStorage) { 12 | self.storage = storage 13 | } 14 | 15 | func states() 16 | -> AnyObservable> { 17 | 18 | typealias State = ResourceState 19 | 20 | 21 | return ValueStream 22 | 24 | > { sendState, disposable in 25 | 26 | disposable += self.loadImpulseEmitter.observe { loadImpulse in 27 | 28 | func sendError(_ error: APIError) { 29 | sendState(State.error( 30 | error: error, 31 | loadImpulse: loadImpulse, 32 | fallbackValueBox: nil 33 | )) 34 | } 35 | 36 | let loadingState = State.loading( 37 | loadImpulse: loadImpulse, 38 | fallbackValueBox: nil, 39 | fallbackError: nil 40 | ) 41 | 42 | sendState(loadingState) 43 | 44 | self.storage.loadInitial(limit: loadImpulse.params.limit, completion: { response in 45 | let state = State.value( 46 | valueBox: EquatableBox(response), 47 | loadImpulse: loadImpulse, 48 | fallbackError: nil 49 | ) 50 | sendState(state) 51 | }) 52 | } 53 | } 54 | .rememberLatestSuccessAndError( 55 | behavior: RememberLatestSuccessAndErrorBehavior( 56 | preferFallbackValueOverFallbackError: true 57 | ) 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Example/DataSourcerer/LoadingCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class LoadingCell : UITableViewCell { 5 | 6 | lazy var loadingIndicatorView: UIActivityIndicatorView = { 7 | let loadingIndicatorView = UIActivityIndicatorView(style: .gray) 8 | loadingIndicatorView.hidesWhenStopped = false 9 | self.contentView.addSubview(loadingIndicatorView) 10 | 11 | loadingIndicatorView.translatesAutoresizingMaskIntoConstraints = false 12 | loadingIndicatorView.topAnchor.constraint(equalTo: self.contentView.topAnchor, 13 | constant: 20).isActive = true 14 | loadingIndicatorView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor).isActive = true 15 | loadingIndicatorView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, 16 | constant: -20).isActive = true 17 | 18 | return loadingIndicatorView 19 | }() 20 | 21 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 22 | super.init(style: style, reuseIdentifier: reuseIdentifier) 23 | 24 | backgroundColor = .white 25 | separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 9_999) 26 | selectionStyle = .none 27 | startAnimating() 28 | } 29 | 30 | func startAnimating() { 31 | loadingIndicatorView.startAnimating() 32 | } 33 | 34 | override func layoutSubviews() { 35 | super.layoutSubviews() 36 | } 37 | 38 | override func prepareForReuse() { 39 | super.prepareForReuse() 40 | startAnimating() 41 | } 42 | 43 | @available(*, unavailable) 44 | required init?(coder aDecoder: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Example/DataSourcerer/NewMessagesChatBotStates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct NewMessagesChatBotStates { 5 | 6 | let storage: ChatBotMockStorage 7 | let loadImpulseEmitter = SimpleLoadImpulseEmitter( 8 | initialImpulse: nil 9 | ) 10 | 11 | init(storage: ChatBotMockStorage) { 12 | self.storage = storage 13 | } 14 | 15 | func states() 16 | -> AnyObservable> { 17 | 18 | typealias State = ResourceState 19 | 20 | return self 21 | .loadNewMessageStatesFromAPI(loadImpulseEmitter: self.loadImpulseEmitter.any) 22 | .reduce { cumulative, next -> State in 23 | 24 | guard let loadImpulse = next.loadImpulse else { 25 | return cumulative ?? next 26 | } 27 | 28 | switch next.provisioningState { 29 | case .loading: 30 | if let cumulative = cumulative { 31 | return State.loading( 32 | loadImpulse: loadImpulse, 33 | fallbackValueBox: cumulative.cacheCompatibleValue(for: loadImpulse), 34 | fallbackError: cumulative.cacheCompatibleError(for: loadImpulse) 35 | ) 36 | } else { 37 | return State.loading( 38 | loadImpulse: loadImpulse, 39 | fallbackValueBox: nil, 40 | fallbackError: nil 41 | ) 42 | } 43 | case .notReady: 44 | return State.notReady 45 | case .result: 46 | 47 | guard let cumulativeResponse = 48 | (cumulative?.cacheCompatibleValue(for: loadImpulse))?.value else { 49 | return next 50 | } 51 | 52 | guard let nextResponse = next.value?.value else { 53 | if let nextError = next.error { 54 | return State.error( 55 | error: nextError, 56 | loadImpulse: loadImpulse, 57 | fallbackValueBox: cumulative?.cacheCompatibleValue(for: loadImpulse) 58 | ) 59 | } else { 60 | return cumulative ?? .notReady 61 | } 62 | } 63 | 64 | return State.value( 65 | valueBox: EquatableBox( 66 | ChatBotResponse( 67 | messages: cumulativeResponse.messages + nextResponse.messages, 68 | currentAvailableActions: nextResponse.currentAvailableActions 69 | ) 70 | ), 71 | loadImpulse: loadImpulse, 72 | fallbackError: next.error 73 | ) 74 | } 75 | } 76 | } 77 | 78 | private func loadNewMessageStatesFromAPI( 79 | loadImpulseEmitter: AnyLoadImpulseEmitter 80 | ) -> ValueStream> { 81 | 82 | typealias State = ResourceState 83 | 84 | return ValueStream 85 | > { sendState, 86 | disposable in 87 | 88 | disposable += loadImpulseEmitter.observe { loadImpulse in 89 | 90 | func sendError(_ error: APIError) { 91 | sendState(State.error( 92 | error: error, 93 | loadImpulse: loadImpulse, 94 | fallbackValueBox: nil 95 | )) 96 | } 97 | 98 | let loadingState = State.loading( 99 | loadImpulse: loadImpulse, 100 | fallbackValueBox: nil, 101 | fallbackError: nil 102 | ) 103 | 104 | sendState(loadingState) 105 | 106 | self.storage.loadNewMessage(completion: { response in 107 | let state = State.value( 108 | valueBox: EquatableBox(response), 109 | loadImpulse: loadImpulse, 110 | fallbackError: nil 111 | ) 112 | sendState(state) 113 | }) 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Example/DataSourcerer/OldMessagesChatBotStates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | struct OldMessagesChatBotStates { 5 | 6 | let storage: ChatBotMockStorage 7 | let loadImpulseEmitter = SimpleLoadImpulseEmitter( 8 | initialImpulse: nil 9 | ) 10 | 11 | init(storage: ChatBotMockStorage) { 12 | self.storage = storage 13 | } 14 | 15 | func states() 16 | -> AnyObservable> { 17 | 18 | typealias State = ResourceState 19 | 20 | return self.loadOldMessageStatesFromAPI(loadImpulseEmitter: self.loadImpulseEmitter.any) 21 | .reduce { cumulative, next -> State in 22 | 23 | guard let loadImpulse = next.loadImpulse else { 24 | return cumulative ?? next 25 | } 26 | 27 | switch next.provisioningState { 28 | case .loading: 29 | if let cumulative = cumulative { 30 | return State.loading( 31 | loadImpulse: loadImpulse, 32 | fallbackValueBox: cumulative.cacheCompatibleValue(for: loadImpulse), 33 | fallbackError: cumulative.cacheCompatibleError(for: loadImpulse) 34 | ) 35 | } else { 36 | return State.loading( 37 | loadImpulse: loadImpulse, 38 | fallbackValueBox: nil, 39 | fallbackError: nil 40 | ) 41 | } 42 | case .notReady: 43 | return State.notReady 44 | case .result: 45 | 46 | guard let cumulativeResponse = 47 | (cumulative?.cacheCompatibleValue(for: loadImpulse))?.value else { 48 | return next 49 | } 50 | 51 | guard let nextResponse = next.value?.value else { 52 | if let nextError = next.error { 53 | return State.error( 54 | error: nextError, 55 | loadImpulse: loadImpulse, 56 | fallbackValueBox: cumulative?.cacheCompatibleValue(for: loadImpulse) 57 | ) 58 | } else { 59 | return cumulative ?? .notReady 60 | } 61 | } 62 | 63 | return State.value( 64 | valueBox: EquatableBox( 65 | ChatBotResponse( 66 | messages: nextResponse.messages + cumulativeResponse.messages, 67 | currentAvailableActions: cumulativeResponse.currentAvailableActions 68 | ) 69 | ), 70 | loadImpulse: loadImpulse, 71 | fallbackError: next.error 72 | ) 73 | } 74 | } 75 | } 76 | 77 | private func loadOldMessageStatesFromAPI( 78 | loadImpulseEmitter: AnyLoadImpulseEmitter 79 | ) -> ValueStream> { 80 | 81 | typealias State = ResourceState 82 | 83 | return ValueStream 84 | > { sendState, 85 | disposable in 86 | 87 | disposable += loadImpulseEmitter.observe { loadImpulse in 88 | 89 | func sendError(_ error: APIError) { 90 | sendState(State.error( 91 | error: error, 92 | loadImpulse: loadImpulse, 93 | fallbackValueBox: nil 94 | )) 95 | } 96 | 97 | let loadingState = State.loading( 98 | loadImpulse: loadImpulse, 99 | fallbackValueBox: nil, 100 | fallbackError: nil 101 | ) 102 | 103 | sendState(loadingState) 104 | 105 | self.storage.loadMoreOldMessages(limit: loadImpulse.params.limit, completion: { response in 106 | let state = State.value( 107 | valueBox: EquatableBox(response), 108 | loadImpulse: loadImpulse, 109 | fallbackError: nil 110 | ) 111 | sendState(state) 112 | }) 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Example/DataSourcerer/PublicRepo.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | 4 | struct PublicRepo : Codable, Equatable { 5 | 6 | let id: Int 7 | let node_id: String? 8 | let name: String? 9 | let full_name: String? 10 | let `private`: Bool 11 | let html_url: String? 12 | let description: String? 13 | let fork: Bool 14 | let url: String? 15 | let forks_url: String? 16 | let keys_url: String? 17 | let collaborators_url: String? 18 | let teams_url: String? 19 | let hooks_url: String? 20 | let issue_events_url: String? 21 | let events_url: String? 22 | let assignees_url: String? 23 | let branches_url: String? 24 | let tags_url: String? 25 | let blobs_url: String? 26 | let git_tags_url: String? 27 | let git_refs_url: String? 28 | let trees_url: String? 29 | let statuses_url: String? 30 | let languages_url: String? 31 | let stargazers_url: String? 32 | let contributors_url: String? 33 | let subscribers_url: String? 34 | let subscription_url: String? 35 | let commits_url: String? 36 | let git_commits_url: String? 37 | let comments_url: String? 38 | let issue_comment_url: String? 39 | let contents_url: String? 40 | let compare_url: String? 41 | let merges_url: String? 42 | let archive_url: String? 43 | let downloads_url: String? 44 | let issues_url: String? 45 | let pulls_url: String? 46 | let milestones_url: String? 47 | let notifications_url: String? 48 | let labels_url: String? 49 | let releases_url: String? 50 | let deployments_url: String? 51 | } 52 | 53 | typealias PublicReposResponse = [PublicRepo] 54 | 55 | enum PublicRepoCell : ItemModel { 56 | typealias E = APIError 57 | 58 | case repo(PublicRepo) 59 | case error(APIError) 60 | 61 | init(error: APIError) { 62 | self = .error(error) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Example/DataSourcerer/PullToRefreshTableViewController.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | import UIKit 4 | 5 | class PullToRefreshTableViewController : UIViewController { 6 | 7 | lazy var viewModel: PullToRefreshTableViewModel = { 8 | PullToRefreshTableViewModel() 9 | }() 10 | 11 | lazy var watchdog = Watchdog(threshold: 0.1, strictMode: false) 12 | 13 | private let disposeBag = DisposeBag() 14 | 15 | private var refreshControl: UIRefreshControl? { 16 | return tableViewController.refreshControl 17 | } 18 | 19 | private lazy var tableViewController = 20 | ListViewDatasourceConfiguration 21 | .buildSingleSectionTableView( 22 | datasource: viewModel.datasource, 23 | withCellModelType: PublicRepoCell.self 24 | ) 25 | .mapSingleSectionItemModels { response, _ -> [PublicRepoCell] in 26 | return response.map { PublicRepoCell.repo($0) } 27 | } 28 | .renderWithCellClass( 29 | cellType: UITableViewCell.self, 30 | dequeueIdentifier: "cell", 31 | configure: { repo, cellView in 32 | cellView.textLabel?.text = { 33 | switch repo { 34 | case let .repo(repo): return repo.name 35 | case .error: return nil 36 | } 37 | }() 38 | } 39 | ) 40 | .configurationForFurtherCustomization 41 | .onDidSelectItem { [weak self] itemSelection in 42 | itemSelection.containingView.deselectRow(at: itemSelection.indexPath, animated: true) 43 | switch itemSelection.itemModel { 44 | case let .repo(repo): 45 | self?.repoSelected(repo: repo) 46 | case .error: 47 | return 48 | } 49 | } 50 | .showLoadingAndErrorStates( 51 | behavior: ShowLoadingAndErrorsBehavior( 52 | errorsBehavior: .preferFallbackValueOverError 53 | ), 54 | noResultsText: "No results", 55 | loadingViewProducer: SimpleTableViewCellProducer.instantiate { _ in return LoadingCell() }, 56 | errorViewProducer: SimpleTableViewCellProducer.instantiate { cell in 57 | guard case let .error(error) = cell else { return ErrorTableViewCell() } 58 | let tableViewCell = ErrorTableViewCell() 59 | tableViewCell.content = error.errorMessage 60 | return tableViewCell 61 | }, 62 | noResultsViewProducer: SimpleTableViewCellProducer.instantiate { _ in 63 | let tableViewCell = ErrorTableViewCell() 64 | tableViewCell.content = StateErrorMessage 65 | .message("Strangely, there are no public repos on Github.") 66 | return tableViewCell 67 | } 68 | ) 69 | .singleSectionTableViewController 70 | .onPullToRefresh { [weak self] in 71 | self?.viewModel.datasource.refresh(type: LoadImpulseType(mode: .fullRefresh, issuer: .user)) 72 | self?.refreshControl?.beginRefreshing() 73 | } 74 | 75 | override func viewDidLoad() { 76 | super.viewDidLoad() 77 | 78 | title = "TableView with Pull to Refresh" 79 | 80 | tableViewController.willMove(toParent: self) 81 | self.addChild(tableViewController) 82 | self.view.addSubview(tableViewController.view) 83 | 84 | let view = tableViewController.view! 85 | view.translatesAutoresizingMaskIntoConstraints = false 86 | view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true 87 | view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 88 | view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true 89 | view.rightAnchor.constraint(equalTo: self.view.rightAnchor).isActive = true 90 | 91 | tableViewController.didMove(toParent: self) 92 | 93 | // Hide pull to refresh when loading finishes 94 | tableViewController.refreshControl?.sourcerer 95 | .endRefreshingOnLoadingEnded(viewModel.datasource) 96 | .disposed(by: disposeBag) 97 | } 98 | 99 | override func viewWillAppear(_ animated: Bool) { 100 | super.viewWillAppear(animated) 101 | 102 | viewModel.loadImpulseEmitter.timerMode = .timeInterval(.seconds(90)) 103 | } 104 | 105 | override func viewDidAppear(_ animated: Bool) { 106 | super.viewDidAppear(animated) 107 | 108 | _ = watchdog // init 109 | } 110 | 111 | override func viewWillDisappear(_ animated: Bool) { 112 | super.viewWillDisappear(animated) 113 | 114 | viewModel.loadImpulseEmitter.timerMode = .none 115 | } 116 | 117 | func repoSelected(repo: PublicRepo) { 118 | print("Repo selected") 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /Example/DataSourcerer/PullToRefreshTableViewModel.swift: -------------------------------------------------------------------------------- 1 | import DataSourcerer 2 | import Foundation 3 | 4 | class PullToRefreshTableViewModel { 5 | 6 | lazy var loadImpulseEmitter: RecurringLoadImpulseEmitter = { 7 | let initialImpulse = LoadImpulse(params: NoResourceParams(), type: .initial) 8 | return RecurringLoadImpulseEmitter(initialImpulse: initialImpulse) 9 | }() 10 | 11 | lazy var datasource: Datasource = { 12 | 13 | return Datasource 14 | .loadFromURL( 15 | urlRequest: { _ -> URLRequest in 16 | let publicReposUrlString: String = "https://api.github.com/repositories" 17 | guard let url = URL(string: publicReposUrlString) else { 18 | throw NSError(domain: "publicReposUrlString is no URL", code: 100, userInfo: nil) 19 | } 20 | 21 | return URLRequest(url: url) 22 | }, 23 | withParameterType: NoResourceParams.self, 24 | expectResponseValueType: PublicReposResponse.self, 25 | failWithError: APIError.self 26 | ) 27 | .setRememberLatestSuccessAndErrorBehavior( 28 | RememberLatestSuccessAndErrorBehavior( 29 | preferFallbackValueOverFallbackError: true 30 | ) 31 | ) 32 | .mapErrorToString { APIError.unknown(.message($0)) } 33 | .loadImpulseBehavior(.instance(loadImpulseEmitter.any)) 34 | .cacheBehavior( 35 | .persist( 36 | persister: CachePersister(key: "public_repos").any, 37 | cacheLoadError: APIError.cacheCouldNotLoad(.default) 38 | ) 39 | ) 40 | .datasource 41 | }() 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Example/DataSourcerer/Watchdog.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Class for logging excessive blocking on the main thread. 4 | final public class Watchdog: NSObject { 5 | fileprivate let pingThread: PingThread 6 | 7 | @objc public static let defaultThreshold = 0.4 8 | 9 | /// Convenience initializer that allows you to construct a `WatchDog` object with default behavior. 10 | /// - parameter threshold: number of seconds that must pass to consider the main thread blocked. 11 | /// - parameter strictMode: boolean value that stops the execution whenever the threshold is reached. 12 | @objc 13 | public convenience init(threshold: Double = Watchdog.defaultThreshold, strictMode: Bool = false) { 14 | let message = "👮 Main thread was blocked for " + String(format:"%.2f", threshold) + "s 👮" 15 | 16 | self.init(threshold: threshold) { 17 | if strictMode { 18 | fatalError(message) 19 | } else { 20 | NSLog("%@", message) 21 | } 22 | } 23 | } 24 | 25 | /// Default initializer that allows you to construct a `WatchDog` object specifying a custom callback. 26 | /// - parameter threshold: number of seconds that must pass to consider the main thread blocked. 27 | /// - parameter watchdogFiredCallback: a callback that will be called when the the threshold is reached 28 | @objc 29 | public init(threshold: Double = Watchdog.defaultThreshold, 30 | watchdogFiredCallback: @escaping () -> Void) { 31 | self.pingThread = PingThread(threshold: threshold, handler: watchdogFiredCallback) 32 | 33 | self.pingThread.start() 34 | super.init() 35 | } 36 | 37 | deinit { 38 | pingThread.cancel() 39 | } 40 | } 41 | 42 | private final class PingThread: Thread { 43 | fileprivate var pingTaskIsRunning: Bool { 44 | get { 45 | objc_sync_enter(pingTaskIsRunningLock) 46 | let result = _pingTaskIsRunning 47 | objc_sync_exit(pingTaskIsRunningLock) 48 | return result 49 | } 50 | set { 51 | objc_sync_enter(pingTaskIsRunningLock) 52 | _pingTaskIsRunning = newValue 53 | objc_sync_exit(pingTaskIsRunningLock) 54 | } 55 | } 56 | private var _pingTaskIsRunning = false 57 | private let pingTaskIsRunningLock = NSObject() 58 | fileprivate var semaphore = DispatchSemaphore(value: 0) 59 | fileprivate let threshold: Double 60 | fileprivate let handler: () -> Void 61 | 62 | init(threshold: Double, handler: @escaping () -> Void) { 63 | self.threshold = threshold 64 | self.handler = handler 65 | super.init() 66 | self.name = "WatchDog" 67 | } 68 | 69 | override func main() { 70 | while !isCancelled { 71 | pingTaskIsRunning = true 72 | DispatchQueue.main.async { 73 | self.pingTaskIsRunning = false 74 | self.semaphore.signal() 75 | } 76 | 77 | Thread.sleep(forTimeInterval: threshold) 78 | if pingTaskIsRunning { 79 | handler() 80 | } 81 | 82 | _ = semaphore.wait(timeout: DispatchTime.distantFuture) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | 3 | target 'DataSourcerer_Example' do 4 | pod 'DataSourcerer', :path => '../' 5 | 6 | target 'DataSourcerer_Tests' do 7 | inherit! :search_paths 8 | 9 | pod 'Quick', '~> 1.3.2' 10 | pod 'Nimble', '~> 7.3.1' 11 | pod 'SwiftLint' 12 | pod 'Reveal-SDK', '19', :configurations => ['Debug'] # view hierarchy inspection 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Cache (5.2.0) 3 | - DataSourcerer (0.2.6): 4 | - DataSourcerer/Core (= 0.2.6) 5 | - DataSourcerer/List (= 0.2.6) 6 | - DataSourcerer/List-UIKit (= 0.2.6) 7 | - DataSourcerer/Persister-Cache (= 0.2.6) 8 | - DataSourcerer/ReactiveSwift (= 0.2.6) 9 | - DataSourcerer/Core (0.2.6) 10 | - DataSourcerer/List (0.2.6): 11 | - DataSourcerer/Core 12 | - DataSourcerer/List-UIKit (0.2.6): 13 | - DataSourcerer/List 14 | - Dwifft (~> 0.9) 15 | - DataSourcerer/Persister-Cache (0.2.6): 16 | - Cache (~> 5.2.0) 17 | - DataSourcerer/Core 18 | - DataSourcerer/ReactiveSwift (0.2.6): 19 | - DataSourcerer/List 20 | - ReactiveSwift (~> 4.0) 21 | - Dwifft (0.9) 22 | - Nimble (7.3.4) 23 | - Quick (1.3.4) 24 | - ReactiveSwift (4.0.0): 25 | - Result (~> 4.0) 26 | - Result (4.1.0) 27 | - Reveal-SDK (19) 28 | - SwiftLint (0.31.0) 29 | 30 | DEPENDENCIES: 31 | - DataSourcerer (from `../`) 32 | - Nimble (~> 7.3.1) 33 | - Quick (~> 1.3.2) 34 | - Reveal-SDK (= 19) 35 | - SwiftLint 36 | 37 | SPEC REPOS: 38 | https://github.com/cocoapods/specs.git: 39 | - Cache 40 | - Dwifft 41 | - Nimble 42 | - Quick 43 | - ReactiveSwift 44 | - Result 45 | - Reveal-SDK 46 | - SwiftLint 47 | 48 | EXTERNAL SOURCES: 49 | DataSourcerer: 50 | :path: "../" 51 | 52 | SPEC CHECKSUMS: 53 | Cache: 807c5d86d01a177f06ede9865add3aea269bbfd4 54 | DataSourcerer: 43f3a5b2de58eb43be92f9e642a2f9ea4fdb553f 55 | Dwifft: 42912068ed2a8146077d1a1404df18625bd086e1 56 | Nimble: 051e3d8912d40138fa5591c78594f95fb172af37 57 | Quick: f4f7f063c524394c73ed93ac70983c609805d481 58 | ReactiveSwift: a2bb9ace428a109e9c0209615645d9d286c8c433 59 | Result: bd966fac789cc6c1563440b348ab2598cc24d5c7 60 | Reveal-SDK: 9f0d2c6c12eb7507921e5454ac165b7aa21140a4 61 | SwiftLint: 7a0227733d786395817373b2d0ca799fd0093ff3 62 | 63 | PODFILE CHECKSUM: 07c5f205d15878e4cdf6d4754a5128caacdd8b8c 64 | 65 | COCOAPODS: 1.6.1 66 | -------------------------------------------------------------------------------- /Example/Tests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | function_body_length: 200 2 | disabled_rules: 3 | - force_unwrapping -------------------------------------------------------------------------------- /Example/Tests/BroadcastObservableSpec.swift: -------------------------------------------------------------------------------- 1 | @testable import DataSourcerer 2 | import Foundation 3 | import Nimble 4 | import Quick 5 | 6 | class SimpleDatasourceSpec: QuickSpec { 7 | 8 | override func spec() { 9 | describe("BroadcastObservable") { 10 | it("should send values to an observer") { 11 | 12 | let observable = BroadcastObservable() 13 | 14 | var observedValues: [Int] = [] 15 | 16 | _ = observable.observe { value in 17 | observedValues.append(value) 18 | } 19 | 20 | observable.emit(1) 21 | observable.emit(2) 22 | 23 | expect(observedValues) == [1,2] 24 | } 25 | it("should not duplicate values with multiple observers subscribed") { 26 | 27 | let observable = BroadcastObservable() 28 | 29 | var observedValues: [Int] = [] 30 | 31 | _ = observable.observe { value in 32 | observedValues.append(value) 33 | } 34 | _ = observable.observe({ _ in }) 35 | 36 | observable.emit(1) 37 | observable.emit(2) 38 | 39 | expect(observedValues) == [1,2] 40 | } 41 | it("should stop sending values to an observer after disposal") { 42 | 43 | let observable = BroadcastObservable() 44 | 45 | var observedValues: [Int] = [] 46 | 47 | let disposable = observable.observe { value in 48 | observedValues.append(value) 49 | } 50 | 51 | observable.emit(1) 52 | observable.emit(2) 53 | disposable.dispose() 54 | observable.emit(3) 55 | 56 | expect(observedValues) == [1,2] 57 | } 58 | it("should release observer after disposal") { 59 | 60 | weak var testStr: NSMutableString? 61 | let observable = BroadcastObservable() 62 | 63 | let testScope: () -> Disposable = { 64 | let innerStr = NSMutableString(string: "") 65 | let disposable = observable.observe { value in 66 | innerStr.append("\(value)") 67 | } 68 | testStr = innerStr 69 | return disposable 70 | } 71 | 72 | let disposable = testScope() 73 | 74 | observable.emit("1") 75 | expect(testStr) == "1" 76 | observable.emit("2") 77 | expect(testStr) == "12" 78 | 79 | disposable.dispose() 80 | 81 | // Force synchronous access to disposable observers so assert works synchronously. 82 | // Alternatively, a wait or waitUntil assert could be used, but this is 83 | // less complex. 84 | expect(disposable.isDisposed) == true 85 | 86 | expect(testStr).to(beNil()) 87 | } 88 | } 89 | } 90 | 91 | } 92 | 93 | internal final class MutableReference { 94 | var value: Value? 95 | 96 | init(_ value: Value?) { 97 | self.value = value 98 | } 99 | } 100 | 101 | internal final class WeakReference { 102 | weak var value: Value? 103 | 104 | init(_ value: Value?) { 105 | self.value = value 106 | } 107 | 108 | var isNil: Bool { 109 | return value == nil 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Example/Tests/DatasourceOperationsSpec.swift: -------------------------------------------------------------------------------- 1 | @testable import DataSourcerer 2 | import Foundation 3 | import Nimble 4 | import Quick 5 | 6 | class DatasourceOperationsSpec: QuickSpec { 7 | 8 | private lazy var testStringLoadImpulse = LoadImpulse(params: "1", type: .initial) 9 | 10 | var stringLoadImpulseEmitter: AnyLoadImpulseEmitter { 11 | return SimpleLoadImpulseEmitter(initialImpulse: testStringLoadImpulse).any 12 | } 13 | 14 | private func testDatasource(_ loadImpulseEmitter: AnyLoadImpulseEmitter) 15 | -> ValueStream> { 16 | 17 | return ValueStream(testStates: OneTwoThreeStringTestStates.oneTwoThreeStringStates, 18 | testError: TestStateError.unknown(description: "Value unavailable"), 19 | loadImpulseEmitter: loadImpulseEmitter) 20 | } 21 | 22 | override func spec() { 23 | describe("Datasource.map") { 24 | it("should send mapped values to observer") { 25 | 26 | let loadImpulseEmitter = self.stringLoadImpulseEmitter 27 | let datasource = self.testDatasource(loadImpulseEmitter) 28 | 29 | let transform: (ResourceState) -> Int? = { state in 30 | return (state.value).flatMap({ Int($0.value) }) 31 | } 32 | 33 | let mapped = datasource.map(transform) 34 | 35 | var observedInts: [Int?] = [] 36 | 37 | let disposable = mapped.observe({ value in 38 | observedInts.append(value) 39 | }) 40 | 41 | loadImpulseEmitter.emit(loadImpulse: LoadImpulse(params: "2", type: LoadImpulseType(mode: .fullRefresh, issuer: .user)), on: .current) 42 | loadImpulseEmitter.emit(loadImpulse: LoadImpulse(params: "3", type: LoadImpulseType(mode: .fullRefresh, issuer: .user)), on: .current) 43 | 44 | disposable.dispose() 45 | 46 | let expectedValues = OneTwoThreeStringTestStates.oneTwoThreeStringStates.map(transform) 47 | expect(observedInts).to(contain(expectedValues)) 48 | } 49 | it("should release observer after disposal") { 50 | 51 | let loadImpulseEmitter = self.stringLoadImpulseEmitter 52 | let datasource = self.testDatasource(loadImpulseEmitter) 53 | 54 | let transform: (ResourceState) -> Int? = { state in 55 | return (state.value).flatMap({ Int($0.value) }) 56 | } 57 | 58 | let mapped = datasource.map(transform) 59 | 60 | weak var testStr: NSMutableString? 61 | 62 | let testScope: () -> Disposable = { 63 | let innerStr = NSMutableString(string: "") 64 | let disposable = mapped.observe({ value in 65 | if let string = value.map({ String($0) }) { 66 | innerStr.append(string) 67 | } 68 | }) 69 | testStr = innerStr 70 | return disposable 71 | } 72 | 73 | let disposable = testScope() 74 | expect(testStr) == "1" 75 | 76 | loadImpulseEmitter.emit(loadImpulse: LoadImpulse(params: "1", type: .initial), on: .current) 77 | expect(testStr) == "11" 78 | 79 | disposable.dispose() 80 | 81 | // Force synchronous access to disposable observers so assert works synchronously. 82 | // Alternatively, a wait or waitUntil assert could be used, but this is 83 | // less complex. 84 | expect(disposable.isDisposed) == true 85 | 86 | expect(testStr).to(beNil()) 87 | } 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Example/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/Tests/LoadImpulseEmitterSpec.swift: -------------------------------------------------------------------------------- 1 | @testable import DataSourcerer 2 | import Foundation 3 | import Nimble 4 | import Quick 5 | 6 | class LoadImpulseEmitterSpec: QuickSpec { 7 | 8 | override func spec() { 9 | describe("SimpleLoadImpulseEmitter") { 10 | it("should send initial load impulse to an observer") { 11 | 12 | let initialImpulse = LoadImpulse(params: "1", type: .initial) 13 | let emitter = SimpleLoadImpulseEmitter(initialImpulse: initialImpulse) 14 | 15 | var observedImpulses: [LoadImpulse] = [] 16 | 17 | _ = emitter.observe({ loadImpulse in 18 | observedImpulses.append(loadImpulse) 19 | }) 20 | 21 | expect(observedImpulses) == [initialImpulse] 22 | } 23 | it("should send multiple load impulses to an observer") { 24 | 25 | let emitter = SimpleLoadImpulseEmitter(initialImpulse: nil) 26 | 27 | var observedImpulses: [LoadImpulse] = [] 28 | 29 | _ = emitter.observe({ loadImpulse in 30 | observedImpulses.append(loadImpulse) 31 | }) 32 | 33 | let impulses = [LoadImpulse(params: "1", type: .initial), LoadImpulse(params: "2", type: LoadImpulseType(mode: .fullRefresh, issuer: .user))] 34 | impulses.forEach({ emitter.emit(loadImpulse: $0, on: .current) }) 35 | 36 | expect(observedImpulses) == impulses 37 | } 38 | it("should not duplicate load impulses with multiple observers subscribed") { 39 | 40 | let emitter = SimpleLoadImpulseEmitter(initialImpulse: nil) 41 | 42 | var observedImpulses: [LoadImpulse] = [] 43 | 44 | _ = emitter.observe({ loadImpulse in 45 | observedImpulses.append(loadImpulse) 46 | }) 47 | _ = emitter.observe({ _ in }) 48 | 49 | let impulses = [LoadImpulse(params: "1", type: .initial), LoadImpulse(params: "2", type: LoadImpulseType(mode: .fullRefresh, issuer: .user))] 50 | impulses.forEach({ emitter.emit(loadImpulse: $0, on: .current) }) 51 | 52 | expect(observedImpulses) == impulses 53 | } 54 | it("should release observer after disposal") { 55 | 56 | weak var testStr: NSMutableString? 57 | let emitter = SimpleLoadImpulseEmitter(initialImpulse: nil) 58 | 59 | let testScope: () -> Disposable = { 60 | let innerStr = NSMutableString(string: "") 61 | let disposable = emitter.observe({ loadImpulse in 62 | innerStr.append("\(loadImpulse.params)") 63 | }) 64 | testStr = innerStr 65 | return disposable 66 | } 67 | 68 | let disposable = testScope() 69 | 70 | emitter.emit(loadImpulse: LoadImpulse(params: "1", type: .initial), on: .current) 71 | expect(testStr) == "1" 72 | emitter.emit(loadImpulse: LoadImpulse(params: "2", type: LoadImpulseType(mode: .fullRefresh, issuer: .user)), on: .current) 73 | expect(testStr) == "12" 74 | 75 | disposable.dispose() 76 | 77 | // Force synchronous access to disposable observers so assert works synchronously. 78 | // Alternatively, a wait or waitUntil assert could be used, but this is 79 | // less complex. 80 | expect(disposable.isDisposed) == true 81 | 82 | expect(testStr).to(beNil()) 83 | } 84 | } 85 | } 86 | 87 | } 88 | 89 | extension String : ResourceParams { 90 | 91 | public func isCacheCompatible(_ candidate: String) -> Bool { 92 | return self.lowercased() == candidate.lowercased() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Example/Tests/PlainCacheDatasourceSpec.swift: -------------------------------------------------------------------------------- 1 | @testable import DataSourcerer 2 | import Foundation 3 | import Nimble 4 | import Quick 5 | 6 | class PlainCacheDatasourceSpec: QuickSpec { 7 | 8 | private lazy var testStringLoadImpulse = LoadImpulse(params: "1", type: .initial) 9 | 10 | private lazy var testStringState: ResourceState = { 11 | return ResourceState( 12 | provisioningState: .result, 13 | loadImpulse: testStringLoadImpulse, 14 | value: EquatableBox("1"), 15 | error: nil 16 | ) 17 | }() 18 | 19 | private func testDatasource(persistedState: ResourceState, 20 | loadImpulseEmitter: SimpleLoadImpulseEmitter) 21 | -> ValueStream> { 22 | 23 | let persister = TestResourceStatePersister() 24 | persister.persist(persistedState) 25 | 26 | return ValueStream(loadStatesFromPersister: persister.any, 27 | loadImpulseEmitter: loadImpulseEmitter.any, 28 | cacheLoadError: TestStateError.cacheCouldNotLoad(.default)) 29 | } 30 | 31 | override func spec() { 32 | describe("PlainCacheDatasource") { 33 | it("should send stored value synchronously if initial load impulse is set") { 34 | let loadImpulseEmitter = SimpleLoadImpulseEmitter( 35 | initialImpulse: self.testStringLoadImpulse 36 | ) 37 | let datasource = self.testDatasource(persistedState: self.testStringState, 38 | loadImpulseEmitter: loadImpulseEmitter) 39 | var observedStates: [ResourceState] = [] 40 | 41 | let disposable = datasource.observe({ state in 42 | observedStates.append(state) 43 | }) 44 | 45 | disposable.dispose() 46 | 47 | expect(observedStates) == [self.testStringState] 48 | } 49 | it("should release observer after disposal") { 50 | let loadImpulseEmitter = SimpleLoadImpulseEmitter( 51 | initialImpulse: self.testStringLoadImpulse 52 | ) 53 | let datasource = self.testDatasource(persistedState: self.testStringState, 54 | loadImpulseEmitter: loadImpulseEmitter) 55 | 56 | weak var testStr: NSMutableString? 57 | 58 | let testScope: () -> Disposable = { 59 | let innerStr = NSMutableString(string: "") 60 | let disposable = datasource.observe({ state in 61 | if let value = state.value?.value { 62 | innerStr.append(value) 63 | } 64 | }) 65 | testStr = innerStr 66 | return disposable 67 | } 68 | 69 | let disposable = testScope() 70 | expect(testStr) == "1" 71 | 72 | loadImpulseEmitter.emit(loadImpulse: LoadImpulse(params: "1", type: .initial), on: .current) 73 | expect(testStr) == "11" 74 | 75 | disposable.dispose() 76 | 77 | // Force synchronous access to disposable observers so assert works synchronously. 78 | // Alternatively, a wait or waitUntil assert could be used, but this is 79 | // less complex. 80 | expect(disposable.isDisposed) == true 81 | 82 | expect(testStr).to(beNil()) 83 | } 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /Example/Tests/TestDatasource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | internal extension ValueStream { 5 | 6 | internal init( 7 | testStates states: [ResourceState], 8 | testError: TestStateError, 9 | loadImpulseEmitter: AnyLoadImpulseEmitter

    ) where ObservedValue == ResourceState { 10 | 11 | self.init( 12 | makeStatesWithClosure: { loadImpulse, sendState -> Disposable in 13 | if let state = states.first(where: { $0.loadImpulse == loadImpulse }) { 14 | sendState(state) 15 | } else { 16 | let errorState = ResourceState.error( 17 | error: testError, 18 | loadImpulse: loadImpulse, 19 | fallbackValueBox: nil 20 | ) 21 | sendState(errorState) 22 | } 23 | 24 | return VoidDisposable() 25 | }, 26 | loadImpulseEmitter: loadImpulseEmitter 27 | ) 28 | } 29 | 30 | } 31 | 32 | struct OneTwoThreeStringTestStates { 33 | 34 | public static var oneTwoThreeStringStates: [ResourceState] = { 35 | return [ 36 | ResourceState.value(valueBox: EquatableBox("1"), 37 | loadImpulse: LoadImpulse(params: "1", type: .initial), 38 | fallbackError: nil), 39 | ResourceState.value(valueBox: EquatableBox("2"), 40 | loadImpulse: LoadImpulse(params: "2", type: LoadImpulseType(mode: .fullRefresh, issuer: .user)), 41 | fallbackError: nil), 42 | ResourceState.value(valueBox: EquatableBox("3"), 43 | loadImpulse: LoadImpulse(params: "3", type: LoadImpulseType(mode: .fullRefresh, issuer: .user)), 44 | fallbackError: nil) 45 | ] 46 | }() 47 | } 48 | -------------------------------------------------------------------------------- /Example/Tests/TestStateError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | enum TestStateError : ResourceError, Codable { 5 | case unknown(description: String?) 6 | case unreachable 7 | case notConnectedToInternet 8 | case deserializationFailed(path: String?, debugDescription: String?, responseSize: Int) 9 | case requestTagsChanged 10 | case notAuthenticated 11 | case cacheCouldNotLoad(StateErrorMessage) 12 | 13 | var errorMessage: StateErrorMessage { 14 | let defaultMessage = NSLocalizedString("An error occurred while loading.\nPlease try again!", comment: "") 15 | let message: String = { 16 | switch self { 17 | case let .unknown(description): 18 | return description ?? defaultMessage 19 | case .unreachable: 20 | return defaultMessage 21 | case .deserializationFailed: 22 | return NSLocalizedString("A critical error occurred while deserializing.", comment: "") 23 | case .requestTagsChanged: 24 | return NSLocalizedString("No data could be loaded for your user.", comment: "") 25 | case .notAuthenticated: 26 | return NSLocalizedString("Please sign in.", comment: "") 27 | case .notConnectedToInternet: 28 | return NSLocalizedString("Please check your internet connectivity.", comment: "") 29 | case let .cacheCouldNotLoad(errorMessage): 30 | switch errorMessage { 31 | case .default: return "Stored data could not be loaded." 32 | case let .message(message): return message 33 | } 34 | } 35 | }() 36 | 37 | return .message(message) 38 | } 39 | 40 | init(message: StateErrorMessage) { 41 | self = .cacheCouldNotLoad(message) 42 | } 43 | } 44 | 45 | extension TestStateError { 46 | 47 | enum CodingKeys: String, CodingKey { 48 | case enumCaseKey = "type" 49 | case deserializationFailed 50 | case deserializationFailedPath 51 | case deserializationFailedDebugDescription 52 | case deserializationFailedResponseSize 53 | case unknown 54 | case unknownDescription 55 | case requestTagsChanged 56 | case unreachable 57 | case notAuthenticated 58 | case notConnectedToInternet 59 | case cacheCouldNotLoad 60 | case cacheCouldNotLoadType 61 | } 62 | 63 | internal init(from decoder: Decoder) throws { 64 | let container = try decoder.container(keyedBy: CodingKeys.self) 65 | 66 | let enumCaseString = try container.decode(String.self, forKey: .enumCaseKey) 67 | guard let enumCase = CodingKeys(rawValue: enumCaseString) else { 68 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case '\(enumCaseString)'")) 69 | } 70 | 71 | switch enumCase { 72 | case .deserializationFailed: 73 | let path = try? container.decode(String.self, forKey: .deserializationFailedPath) 74 | let debugDescription = try? container.decode(String.self, forKey: .deserializationFailedDebugDescription) 75 | let responseSize = try? container.decode(Int.self, forKey: .deserializationFailedResponseSize) 76 | self = .deserializationFailed(path: (path ?? "no path available"), debugDescription: debugDescription, responseSize: responseSize ?? 0) 77 | case .unknown: 78 | let unknownDecription = try? container.decode(String.self, forKey: .unknownDescription) 79 | self = .unknown(description: unknownDecription) 80 | case .notAuthenticated: 81 | self = .notAuthenticated 82 | case .requestTagsChanged: 83 | self = .requestTagsChanged 84 | case .unreachable: 85 | self = .unreachable 86 | case .notConnectedToInternet: 87 | self = .notConnectedToInternet 88 | case .cacheCouldNotLoad: 89 | let type = try? container.decode(StateErrorMessage.self, forKey: .cacheCouldNotLoadType) 90 | self = .cacheCouldNotLoad(type ?? .default) 91 | default: throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case '\(enumCase)'")) 92 | } 93 | } 94 | 95 | internal func encode(to encoder: Encoder) throws { 96 | var container = encoder.container(keyedBy: CodingKeys.self) 97 | 98 | switch self { 99 | case let .deserializationFailed(path, debugDescription, responseSize): 100 | try container.encode(CodingKeys.deserializationFailed.rawValue, forKey: .enumCaseKey) 101 | if let path = path { 102 | try container.encode(path, forKey: .deserializationFailedPath) 103 | } 104 | if let debugDescription = debugDescription { 105 | try container.encode(debugDescription, forKey: .deserializationFailedDebugDescription) 106 | } 107 | try container.encode(responseSize, forKey: .deserializationFailedResponseSize) 108 | case let .unknown(description): 109 | try container.encode(CodingKeys.unknown.rawValue, forKey: .enumCaseKey) 110 | try container.encode(description, forKey: .unknownDescription) 111 | case .requestTagsChanged: 112 | try container.encode(CodingKeys.requestTagsChanged.rawValue, forKey: .enumCaseKey) 113 | case .unreachable: 114 | try container.encode(CodingKeys.unreachable.rawValue, forKey: .enumCaseKey) 115 | case .notAuthenticated: 116 | try container.encode(CodingKeys.notAuthenticated.rawValue, forKey: .enumCaseKey) 117 | case .notConnectedToInternet: 118 | try container.encode(CodingKeys.notConnectedToInternet.rawValue, forKey: .enumCaseKey) 119 | case let .cacheCouldNotLoad(type): 120 | try container.encode(CodingKeys.cacheCouldNotLoad.rawValue, forKey: .enumCaseKey) 121 | try container.encode(type, forKey: .cacheCouldNotLoadType) 122 | } 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /Example/Tests/TestStatePersister.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import DataSourcerer 3 | 4 | public class TestResourceStatePersister: ResourceStatePersister { 5 | public typealias Value = Value_ 6 | public typealias P = P_ 7 | public typealias E = E_ 8 | 9 | public typealias StatePersistenceKey = String 10 | 11 | private var state: PersistedState? 12 | 13 | public func persist(_ state: PersistedState) { 14 | self.state = state 15 | } 16 | 17 | public func load(_ parameters: P) -> PersistedState? { 18 | guard let state = self.state, 19 | state.loadImpulse?.params.isCacheCompatible(parameters) ?? false else { 20 | return nil 21 | } 22 | 23 | return state 24 | } 25 | 26 | public func purge() { 27 | state = nil 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Creative Pragmatics GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | DataSourcerer—Sending Data to Views without Magic. A Swift library. 3 |

    4 | 5 | [![CI Status](https://img.shields.io/travis/creativepragmatics/DataSourcerer.svg?style=flat)](https://travis-ci.org/creativepragmatics/DataSourcerer) 6 | [![Version](https://img.shields.io/cocoapods/v/DataSourcerer.svg?style=flat)](https://cocoapods.org/pods/DataSourcerer) 7 | [![License](https://img.shields.io/cocoapods/l/DataSourcerer.svg?style=flat)](https://cocoapods.org/pods/DataSourcerer) 8 | [![Platform](https://img.shields.io/cocoapods/p/DataSourcerer.svg?style=flat)](https://cocoapods.org/pods/DataSourcerer) 9 | 10 | ## What is a DataSourcerer? 11 | 12 | This Swift library lets you connect an API call (or any other datasource) to the view layer within minutes. It has a ready-to-go `UITableViewDatasource` and `UICollectionViewDatasource` along with matching ViewControllers and is built in a way that datasources for other view types (e.g. `UIStackView`) can be easily composed. 13 | 14 | An idiomatic tableview that displays data from an API call and supports: 15 | * pull-to-refresh, 16 | * on-disk-caching, 17 | * clear-view-on-logout, 18 | * a loading indicator if no cached data is shown (e.g. on first app start), 19 | * displaying "no results" in a dedicated cell if there aren't any, 20 | * displaying errors in a dedicated cell 21 | 22 | can be setup with ~100 lines of configuration (see [Example](Example/DataSourcerer)). 23 | 24 | ## Usage 25 | 26 | The best way to use this library is to create a `Datasource`, and connect it to one or more 27 | Table/CollectionViewControllers by using the builder pattern provided by `ListViewDatasourceConfiguration`. 28 | Features, like showing errors as items/cells, are added in this configuration before creating the actual Table/CollectionViewController. 29 | 30 | You can subscribe to changes in the `Datasource` e.g. for stopping the Pull to Refresh indicator after loading is done. 31 | 32 | ## How does this work? 33 | 34 | DataSourcerer can be viewed as two parts: 35 | 1. A very basic [FRP](https://en.wikipedia.org/wiki/Functional_reactive_programming) framework 36 | 2. View adapters that subscribe to the FRP framework's structures to do work, like refreshing subviews. The adapters are split into many structs and classes, but Builder patterns added at the crucial points should provide good usability for the most common usage scenarios. 37 | 38 | You may ask, who needs another FRP framework, why reinvent the wheel? There are various reasons this project has its own FRP configuration: 39 | * Reducing references to projects that are not under our control 40 | * Keeping development cadence (e.g. with new Swift releases) independent of other projects 41 | * Avoid binding Datasourcerer users to a specific ReactiveSwift/RxSwift/ReactiveKit/... version (especially annoying for Cocoapods users) 42 | * The self-built approach seems to be easier to debug, because it has way less levels of abstraction due to the reduced feature set. 43 | 44 | ## Is it tested? 45 | 46 | [Yes](Example/Tests). More tests are expected to be added within Q1 2019. The goal is to reach 100% coverage eventually. 47 | 48 | ## Example 49 | 50 | To run the example project, clone the repo, and run `pod install` from the Example directory first. 51 | 52 | ## Requirements 53 | 54 | ## Installation 55 | 56 | DataSourcerer will __soon__ be available through [CocoaPods](https://cocoapods.org). To install 57 | it, simply add the following line to your Podfile: 58 | 59 | ```ruby 60 | pod 'DataSourcerer' 61 | ``` 62 | 63 | ## Roadmap 64 | 65 | Q1 2019: 66 | 67 | * Add seamless interaction with various Rx libraries 68 | * Add missing IdiomaticSectionedTableViewDatasource 69 | * Up test coverage to 100% 70 | 71 | Later, but ASAP: 72 | * AloeStackView support 73 | 74 | ## Author 75 | 76 | Manuel Maly, manuel@creativepragmatics.com 77 | 78 | ## License 79 | 80 | DataSourcerer is available under the MIT license. See the LICENSE file for more info. 81 | -------------------------------------------------------------------------------- /_Pods.xcodeproj: -------------------------------------------------------------------------------- 1 | Example/Pods/Pods.xcodeproj --------------------------------------------------------------------------------