diff --git a/Cartfile.private b/Cartfile.private index f55c6a8..0002688 100644 --- a/Cartfile.private +++ b/Cartfile.private @@ -1,6 +1,5 @@ # Example app. github "ReactiveCocoa/ReactiveCocoa" ~> 10.0.0 -github "onevcat/Kingfisher" ~> 5.2 # Tests github "Quick/Nimble" ~> 8.0 diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift index e6bd194..5758568 100644 --- a/Example/AppDelegate.swift +++ b/Example/AppDelegate.swift @@ -14,7 +14,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Comment these lines if you want to see multi store example + window?.rootViewController = RootViewController() + window?.makeKeyAndVisible() return true } } - diff --git a/Example/Base.lproj/Main.storyboard b/Example/Base.lproj/Main.storyboard index e03a35f..cc960e1 100644 --- a/Example/Base.lproj/Main.storyboard +++ b/Example/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + diff --git a/Example/MultiStoreExample/PaginationViewController.swift b/Example/MultiStoreExample/PaginationViewController.swift new file mode 100644 index 0000000..323e7dd --- /dev/null +++ b/Example/MultiStoreExample/PaginationViewController.swift @@ -0,0 +1,129 @@ +import UIKit +import ReactiveSwift +import ReactiveCocoa +import ReactiveFeedback + +final class PaginationViewController: UIViewController { + private lazy var contentView = MoviesView.loadFromNib() + private let viewModel = Movies.ViewModel() + + override func loadView() { + self.view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.state.producer.startWithValues(contentView.render) + } +} + +extension Movies { + final class ViewModel: Store { + init() { + super.init( + initial: Movies.State(), + reducer: Movies.reduce, + feedbacks: [Movies.feedback] + ) + } + } +} + +// Key for https://www.themoviedb.org API +let correctKey = "d4f0bdb3e246e2cb3555211e765c89e3" + +struct Results: Codable { + let page: Int + let totalResults: Int + let totalPages: Int + let results: [Movie] + + static func empty() -> Results { + return Results.init(page: 0, totalResults: 0, totalPages: 0, results: []) + } + + enum CodingKeys: String, CodingKey { + case page + case totalResults = "total_results" + case totalPages = "total_pages" + case results + } +} + +struct Movie: Codable { + let id: Int + let overview: String + let title: String + let posterPath: String? + + var posterURL: URL? { + return posterPath + .map { + "https://image.tmdb.org/t/p/w342/\($0)" + } + .flatMap(URL.init(string:)) + } + + enum CodingKeys: String, CodingKey { + case id + case overview + case title + case posterPath = "poster_path" + } +} + +extension URLSession { + func fetchMovies(page: Int) -> SignalProducer { + return SignalProducer.init({ (observer, lifetime) in + let url = URL(string: "https://api.themoviedb.org/3/discover/movie?api_key=\(correctKey)&sort_by=popularity.desc&page=\(page)")! + let task = self.dataTask(with: url, completionHandler: { (data, response, error) in + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 { + let error = NSError(domain: "come.reactivefeedback", + code: 401, + userInfo: [NSLocalizedDescriptionKey: "Forced failure to illustrate Retry"]) + observer.send(error: error) + } else if let data = data { + do { + let results = try JSONDecoder().decode(Results.self, from: data) + observer.send(value: results) + } catch { + observer.send(error: error as NSError) + } + } else if let error = error { + observer.send(error: error as NSError) + observer.sendCompleted() + } else { + observer.sendCompleted() + } + }) + + lifetime += AnyDisposable(task.cancel) + task.resume() + }) + } +} + +final class ArrayCollectionViewDataSource: NSObject, UICollectionViewDataSource { + typealias CellFactory = (UICollectionView, IndexPath, T) -> UICollectionViewCell + + private(set) var items: [T] = [] + var cellFactory: CellFactory! + + func update(with items: [T]) { + self.items = items + } + + func item(atIndexPath indexPath: IndexPath) -> T { + return items[indexPath.row] + } + + // MARK: UICollectionViewDataSource + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + return cellFactory(collectionView, indexPath, item(atIndexPath: indexPath)) + } +} diff --git a/Example/TextInputViewController.swift b/Example/MultiStoreExample/TextInputViewController.swift similarity index 100% rename from Example/TextInputViewController.swift rename to Example/MultiStoreExample/TextInputViewController.swift diff --git a/Example/MultiStoreExample/ViewController.swift b/Example/MultiStoreExample/ViewController.swift new file mode 100644 index 0000000..39d8e7f --- /dev/null +++ b/Example/MultiStoreExample/ViewController.swift @@ -0,0 +1,33 @@ +import UIKit +import ReactiveSwift +import ReactiveCocoa +import ReactiveFeedback + +class ViewController: UIViewController { + @IBOutlet weak var plusButton: UIButton! + @IBOutlet weak var minusButton: UIButton! + @IBOutlet weak var label: UILabel! + private lazy var contentView = CounterView.loadFromNib() + private let viewModel = Counter.ViewModel() + + override func loadView() { + self.view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.state.producer.startWithValues(contentView.render) + } +} + +extension Counter { + final class ViewModel: Store { + init() { + super.init( + initial: State(), + reducer: Counter.reduce, + feedbacks: [] + ) + } + } +} diff --git a/Example/PaginationViewController.swift b/Example/PaginationViewController.swift deleted file mode 100644 index a5209d5..0000000 --- a/Example/PaginationViewController.swift +++ /dev/null @@ -1,490 +0,0 @@ -// -// PaginationViewController.swift -// ReactiveFeedback -// -// Created by sergdort on 29/08/2017. -// Copyright © 2017 sergdort. All rights reserved. -// - -import UIKit -import ReactiveSwift -import ReactiveCocoa -import Kingfisher -import ReactiveFeedback - -final class PaginationViewController: UICollectionViewController { - let dataSource = ArrayCollectionViewDataSource() - let viewModel = PaginationViewModel() - private let (retrySignal, retryObserver) = Signal.pipe() - - override func viewDidLoad() { - super.viewDidLoad() - setupDataSource() - bindViewModel() - } - - func bindViewModel() { - viewModel.nearBottomBinding <~ collectionView!.rac_nearBottomSignal - viewModel.retryBinding <~ retrySignal - viewModel.movies.producer.bind(with: collectionView!.rac_items(dataSource: dataSource)) - viewModel.errors.producer - .skipNil() - .startWithValues { [weak self] in - self?.showAlert(for: $0) - } - } - - func setupDataSource() { - dataSource.cellFactory = { cv, ip, item in - let cell = cv.dequeueReusableCell(withReuseIdentifier: "MoviewCell", for: ip) as! MoviewCell - cell.configure(with: item) - return cell - } - } - - func showAlert(for error: NSError) { - let alert = UIAlertController(title: "Error", - message: error.localizedDescription, - preferredStyle: .alert) - let action = UIAlertAction(title: "Retry", style: .cancel, handler: { _ in - self.retryObserver.send(value: ()) - }) - alert.addAction(action) - present(alert, animated: true, completion: nil) - } -} - -final class PaginationViewModel { - private let token = Lifetime.Token() - private var lifetime: Lifetime { - return Lifetime(token) - } - private let nearBottomObserver: Signal.Observer - private let retryObserver: Signal.Observer - - private let stateProperty: Property - let movies: Property<[Movie]> - let errors: Property - let refreshing: Property - - var nearBottomBinding: BindingTarget { - return BindingTarget(lifetime: lifetime) { value in - self.nearBottomObserver.send(value: value) - } - } - - var retryBinding: BindingTarget { - return BindingTarget(lifetime: lifetime) { value in - self.retryObserver.send(value: value) - } - } - - init() { - let (nearBottomSignal, nearBottomObserver) = Signal.pipe() - let (retrySignal, retryObserver) = Signal.pipe() - let feedbacks = [ - Feedbacks.loadNextFeedback(for: nearBottomSignal), - Feedbacks.pagingFeedback(), - Feedbacks.retryFeedback(for: retrySignal), - Feedbacks.retryPagingFeedback() - ] - - self.stateProperty = Property(initial: State.initial, - reduce: State.reduce, - feedbacks: feedbacks) - - self.movies = Property<[Movie]>(initial: [], - then: stateProperty.producer.filterMap { $0.newMovies }) - - self.errors = stateProperty.map { $0.lastError } - self.refreshing = stateProperty.map { $0.isRefreshing } - self.nearBottomObserver = nearBottomObserver - self.retryObserver = retryObserver - } - - enum Feedbacks { - static func loadNextFeedback(for nearBottomSignal: Signal) -> Feedback { - return Feedback(predicate: { !$0.paging }) { _ in - nearBottomSignal - .map { Event.startLoadingNextPage } - } - } - - static func pagingFeedback() -> Feedback { - return Feedback(skippingRepeated: { $0.nextPage }) { (nextPage) -> SignalProducer in - URLSession.shared.fetchMovies(page: nextPage) - .map(Event.response) - .flatMapError { error in - SignalProducer(value: Event.failed(error)) - }.observe(on: UIScheduler()) - } - } - - static func retryFeedback(for retrySignal: Signal) -> Feedback { - return Feedback(skippingRepeated: { $0.lastError }) { _ -> Signal in - retrySignal.map { Event.retry } - } - } - - static func retryPagingFeedback() -> Feedback { - return Feedback(skippingRepeated: { $0.retryPage }) { (nextPage) -> SignalProducer in - URLSession.shared.fetchMovies(page: nextPage) - .map(Event.response) - .flatMapError { error in - SignalProducer(value: Event.failed(error)) - }.observe(on: UIScheduler()) - } - } - } - - struct Context { - var batch: Results - var movies: [Movie] - - static var empty: Context { - return Context(batch: Results.empty(), movies: []) - } - } - - enum State { - case initial - case paging(context: Context) - case loadedPage(context: Context) - case refreshing(context: Context) - case refreshed(context: Context) - case error(error: NSError, context: Context) - case retry(context: Context) - - var newMovies: [Movie]? { - switch self { - case .paging(context:let context): - return context.movies - case .loadedPage(context:let context): - return context.movies - case .refreshed(context:let context): - return context.movies - default: - return nil - } - } - - var context: Context { - switch self { - case .initial: - return Context.empty - case .paging(context:let context): - return context - case .loadedPage(context:let context): - return context - case .refreshing(context:let context): - return context - case .refreshed(context:let context): - return context - case .error(error:_, context:let context): - return context - case .retry(context:let context): - return context - } - } - - var movies: [Movie] { - return context.movies - } - - var batch: Results { - return context.batch - } - - var refreshPage: Int? { - switch self { - case .refreshing: - return nil - default: - return 1 - } - } - - var nextPage: Int? { - switch self { - case .paging(context:let context): - return context.batch.page + 1 - case .refreshed(context:let context): - return context.batch.page + 1 - default: - return nil - } - } - - var retryPage: Int? { - switch self { - case .retry(context:let context): - return context.batch.page + 1 - default: - return nil - } - } - - var lastError: NSError? { - switch self { - case .error(error:let error, context:_): - return error - default: - return nil - } - } - - var isRefreshing: Bool { - switch self { - case .refreshing: - return true - default: - return false - } - } - - var paging: Bool { - switch self { - case .paging: - return true - default: - return false - } - } - - static func reduce(state: State, event: Event) -> State { - switch event { - case .startLoadingNextPage: - return .paging(context: state.context) - case .response(let batch): - var copy = state.context - copy.batch = batch - copy.movies += batch.results - return .loadedPage(context: copy) - case .failed(let error): - return .error(error: error, context: state.context) - case .retry: - return .retry(context: state.context) - } - } - } - - enum Event { - case startLoadingNextPage - case response(Results) - case failed(NSError) - case retry - } -} - -// MARK: - ⚠️ Danger ⚠️ Boilerplate - -final class MoviewCell: UICollectionViewCell { - @IBOutlet weak var title: UILabel! - @IBOutlet weak var imageView: UIImageView! - - override func prepareForReuse() { - super.prepareForReuse() - self.title.text = nil - self.imageView.image = nil - } - - func configure(with moview: Movie) { - title.text = moview.title - imageView.kf.setImage(with: moview.posterURL, - options: [KingfisherOptionsInfoItem.transition(ImageTransition.fade(0.2))]) - } -} - -extension UIScrollView { - var rac_contentOffset: Signal { - return self.reactive.signal(forKeyPath: "contentOffset") - .filterMap { change in - guard let value = change as? NSValue else { - return nil - } - return value.cgPointValue - } - } - - var rac_nearBottomSignal: Signal { - func isNearBottomEdge(scrollView: UIScrollView, edgeOffset: CGFloat = 44.0) -> Bool { - return scrollView.contentOffset.y + scrollView.frame.size.height + edgeOffset > scrollView.contentSize.height - } - - return rac_contentOffset - .filterMap { _ in - if isNearBottomEdge(scrollView: self) { - return () - } - return nil - } - } -} - - -// Key for https://www.themoviedb.org API -let apiKey = "" -let correctKey = "d4f0bdb3e246e2cb3555211e765c89e3" - -struct Results: Codable { - let page: Int - let totalResults: Int - let totalPages: Int - let results: [Movie] - - static func empty() -> Results { - return Results.init(page: 0, totalResults: 0, totalPages: 0, results: []) - } - - enum CodingKeys: String, CodingKey { - case page - case totalResults = "total_results" - case totalPages = "total_pages" - case results - } -} - -struct Movie: Codable { - let id: Int - let overview: String - let title: String - let posterPath: String? - - var posterURL: URL? { - return posterPath - .map { - "https://image.tmdb.org/t/p/w342/\($0)" - } - .flatMap(URL.init(string:)) - } - - enum CodingKeys: String, CodingKey { - case id - case overview - case title - case posterPath = "poster_path" - } -} - -var shouldFail = false - -func switchFail() { - shouldFail = !shouldFail -} - -extension URLSession { - func fetchMovies(page: Int) -> SignalProducer { - return SignalProducer.init({ (observer, lifetime) in - let url = URL(string: "https://api.themoviedb.org/3/discover/movie?api_key=\(shouldFail ? apiKey : correctKey)&sort_by=popularity.desc&page=\(page)")! - switchFail() - let task = self.dataTask(with: url, completionHandler: { (data, response, error) in - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 { - let error = NSError(domain: "come.reactivefeedback", - code: 401, - userInfo: [NSLocalizedDescriptionKey: "Forced failure to illustrate Retry"]) - observer.send(error: error) - } else if let data = data { - do { - let results = try JSONDecoder().decode(Results.self, from: data) - observer.send(value: results) - } catch { - observer.send(error: error as NSError) - } - } else if let error = error { - observer.send(error: error as NSError) - observer.sendCompleted() - } else { - observer.sendCompleted() - } - }) - - lifetime += AnyDisposable(task.cancel) - task.resume() - }) - } -} - -extension UICollectionView { - func rac_items(dataSource: DataSource) -> (S) -> Disposable? where S.Error == Never, S.Value == DataSource.Element { - return { source in - self.dataSource = dataSource - return source.signal.observe({ [weak self] (event) in - guard let tableView = self else { - return - } - dataSource.collectionView(tableView, observedEvent: event) - }) - } - } - - func rac_items(dataSource: DataSource) -> (S) -> Disposable? where S.Error == Never, S.Value == DataSource.Element { - return { source in - self.dataSource = dataSource - return source.producer.start { [weak self] event in - guard let tableView = self else { - return - } - dataSource.collectionView(tableView, observedEvent: event) - } - } - } -} - -public protocol RACCollectionViewDataSourceType { - associatedtype Element - - func collectionView(_ collectionView: UICollectionView, observedEvent: Signal.Event) -} - -final class ArrayCollectionViewDataSource: NSObject, UICollectionViewDataSource { - typealias CellFactory = (UICollectionView, IndexPath, T) -> UICollectionViewCell - - private var items: [T] = [] - var cellFactory: CellFactory! - - func update(with items: [T]) { - self.items = items - } - - func item(atIndexPath indexPath: IndexPath) -> T { - return items[indexPath.row] - } - - // MARK: UICollectionViewDataSource - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return items.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - return cellFactory(collectionView, indexPath, item(atIndexPath: indexPath)) - } -} - -extension ArrayCollectionViewDataSource: RACCollectionViewDataSourceType { - func collectionView(_ collectionView: UICollectionView, observedEvent: Signal<[T], Never>.Event) { - switch observedEvent { - case .value(let value): - update(with: value) - collectionView.reloadData() - case .failed(let error): - assertionFailure("Bind error \(error)") - default: - break - } - } -} - -extension SignalProducerProtocol { - @discardableResult - func bind(with: (SignalProducer) -> R) -> R { - return with(self.producer) - } -} - -extension SignalProtocol { - @discardableResult - func bind(with: (Signal) -> R) -> R { - return with(self.signal) - } -} diff --git a/Example/SingleStoreExample/ColorPicker/ColorPicker.swift b/Example/SingleStoreExample/ColorPicker/ColorPicker.swift new file mode 100644 index 0000000..16ae6d7 --- /dev/null +++ b/Example/SingleStoreExample/ColorPicker/ColorPicker.swift @@ -0,0 +1,19 @@ +import UIKit + +enum ColorPicker { + struct State: Builder { + let colors: [UIColor] = [.green, .yellow, .red] + var selectedColor: UIColor + } + + enum Event { + case didPick(UIColor) + } + + static func reduce(state: inout State, event: Event) { + switch event { + case .didPick(let color): + state.selectedColor = color + } + } +} diff --git a/Example/SingleStoreExample/ColorPicker/ColorPickerView.swift b/Example/SingleStoreExample/ColorPicker/ColorPickerView.swift new file mode 100644 index 0000000..53b98b6 --- /dev/null +++ b/Example/SingleStoreExample/ColorPicker/ColorPickerView.swift @@ -0,0 +1,38 @@ +import UIKit +import ReactiveFeedback + +final class ColorPickerViewController: ContainerViewController { + private let store: Store + + init(store: Store) { + self.store = store + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + store.state.producer.startWithValues(contentView.render) + } +} + +final class ColorPickerView: UIView, NibLoadable { + @IBOutlet var stackView: UIStackView! + let didTapButton = CommandWith() + + func render(context: Context) { + zip(stackView.arrangedSubviews, context.colors).forEach { (view, color) in + view.backgroundColor = color + } + didTapButton.action = { color in + context.send(event: .didPick(color)) + } + } + + @IBAction func didTapButton(sender: UIButton) { + didTapButton.action(sender.backgroundColor ?? .clear) + } +} diff --git a/Example/SingleStoreExample/ColorPicker/ColorPickerView.xib b/Example/SingleStoreExample/ColorPicker/ColorPickerView.xib new file mode 100644 index 0000000..21f9fcc --- /dev/null +++ b/Example/SingleStoreExample/ColorPicker/ColorPickerView.xib @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/SingleStoreExample/Counter/Counter.swift b/Example/SingleStoreExample/Counter/Counter.swift new file mode 100644 index 0000000..829f5a1 --- /dev/null +++ b/Example/SingleStoreExample/Counter/Counter.swift @@ -0,0 +1,33 @@ +import ReactiveFeedback +import ReactiveSwift + +enum Counter { + struct State: Builder { + var count = 0 + } + + enum Event { + case increment + case decrement + } + + static func reduce(state: inout State, event: Event) { + switch event { + case .increment: + state.count += 1 + case .decrement: + state.count -= 1 + } + } +} + +protocol Builder {} +extension Builder { + func set(_ keyPath: WritableKeyPath, _ value: T) -> Self { + var copy = self + copy[keyPath: keyPath] = value + return copy + } +} + +extension NSObject: Builder {} diff --git a/Example/SingleStoreExample/Counter/CounterView.swift b/Example/SingleStoreExample/Counter/CounterView.swift new file mode 100644 index 0000000..217cc26 --- /dev/null +++ b/Example/SingleStoreExample/Counter/CounterView.swift @@ -0,0 +1,71 @@ +import UIKit +import ReactiveFeedback + +final class CounterViewController: ContainerViewController { + private let store: Store + + init(store: Store) { + self.store = store + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + store.state.producer.startWithValues(contentView.render) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class CounterView: UIView, NibLoadable { + @IBOutlet weak var plusButton: UIButton! + @IBOutlet weak var minusButton: UIButton! + @IBOutlet weak var label: UILabel! + private let plusButtonDidTap = Command() + private let minusButtonDidTap = Command() + + @IBAction + private func plusButtonPressed() { + plusButtonDidTap.action() + } + + @IBAction + private func minusButtonPressed() { + minusButtonDidTap.action() + } + + func render(context: Context) { + label.text = "\(context.count)" + plusButtonDidTap.action = { + context.send(event: .increment) + } + minusButtonDidTap.action = { + context.send(event: .decrement) + } + } +} + + +final class Command { + var action: () -> Void = {} +} + +final class CommandWith { + var action: (T) -> Void = { _ in } +} + +public protocol NibLoadable { + static var nib: UINib { get } +} + +public extension NibLoadable where Self: UIView { + static var nib: UINib { + return UINib(nibName: String(describing: self), bundle: Bundle(for: self)) + } + + static func loadFromNib() -> Self { + return nib.instantiate(withOwner: nil, options: nil).first as! Self + } +} diff --git a/Example/SingleStoreExample/Counter/CounterView.xib b/Example/SingleStoreExample/Counter/CounterView.xib new file mode 100644 index 0000000..b4c6e0c --- /dev/null +++ b/Example/SingleStoreExample/Counter/CounterView.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/SingleStoreExample/Movies/MovieCell.swift b/Example/SingleStoreExample/Movies/MovieCell.swift new file mode 100644 index 0000000..4413ce4 --- /dev/null +++ b/Example/SingleStoreExample/Movies/MovieCell.swift @@ -0,0 +1,62 @@ +import UIKit +import ReactiveSwift +import ReactiveCocoa + + +class ImageFetcher { + static let shared = ImageFetcher() + + private let cache = NSCache() + + func image(for url: URL) -> SignalProducer { + return SignalProducer.deferred { + if let image = self.cache.object(forKey: url as NSURL) { + return SignalProducer(value: image) + } + return URLSession.shared.reactive.data(with: URLRequest(url: url)) + .map { $0.0 } + .map(UIImage.init(data:)) + .skipNil() + .on(value: { + self.cache.setObject($0, forKey: url as NSURL) + }) + .flatMapError { _ in SignalProducer(value: UIImage()) } + .observe(on: UIScheduler()) + } + } +} + +extension SignalProducer { + static func deferred(_ producer: @escaping () -> SignalProducer) -> SignalProducer { + return SignalProducer { $1 += producer().start($0) } + } +} + + +final class MovieCell: UICollectionViewCell, NibLoadable { + @IBOutlet weak var title: UILabel! + @IBOutlet weak var imageView: UIImageView! { + didSet { + imageView.backgroundColor = .gray + } + } + private var disposable: Disposable? { + willSet { + disposable?.dispose() + } + } + + override func prepareForReuse() { + super.prepareForReuse() + self.title.text = nil + self.imageView.image = nil + } + + func configure(with movie: Movie) { + title.text = movie.title + disposable = (movie.posterURL.map(ImageFetcher.shared.image(for:)) ?? .empty) + .startWithValues { [weak self] in + self?.imageView.image = $0 + } + } +} diff --git a/Example/SingleStoreExample/Movies/MovieCell.xib b/Example/SingleStoreExample/Movies/MovieCell.xib new file mode 100644 index 0000000..5960ad6 --- /dev/null +++ b/Example/SingleStoreExample/Movies/MovieCell.xib @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/SingleStoreExample/Movies/Movies.swift b/Example/SingleStoreExample/Movies/Movies.swift new file mode 100644 index 0000000..4241115 --- /dev/null +++ b/Example/SingleStoreExample/Movies/Movies.swift @@ -0,0 +1,170 @@ +import Foundation +import ReactiveFeedback +import ReactiveSwift +import UIKit + +enum Movies { + struct State: Builder { + var batch: Results = .empty() + var movies: [Movie] = [] + var status: Status = .initial + var backgroundColor = UIColor.black + + var nextPage: Int? { + switch status { + case .initial: + return 1 + case .paging: + return batch.page + 1 + case .refreshed: + return batch.page + 1 + default: + return nil + } + } + + var refreshPage: Int? { + switch status { + case .refreshing: + return nil + default: + return 1 + } + } + + var retryPage: Int? { + switch status { + case .retry: + return batch.page + 1 + default: + return nil + } + } + + var lastError: NSError? { + switch status { + case .error(let error): + return error + default: + return nil + } + } + + var isRefreshing: Bool { + switch status { + case .refreshing: + return true + default: + return false + } + } + + var paging: Bool { + switch status { + case .paging: + return true + default: + return false + } + } + + var colorPicker: ColorPicker.State { + get { + ColorPicker.State(selectedColor: backgroundColor) + } + set { + self.backgroundColor = newValue.selectedColor + } + } + } + + enum Status { + case initial + case paging + case loadedPage + case refreshing + case refreshed + case error(NSError) + case retry + } + + enum Event { + case startLoadingNextPage + case response(Results) + case failed(NSError) + case retry + case picker(ColorPicker.Event) + + var colorPicker: ColorPicker.Event? { + get { + guard case let .picker(value) = self else { return nil } + return value + } + set { + guard case .picker = self, let newValue = newValue else { return } + self = .picker(newValue) + } + } + } + + static var feedback: FeedbackLoop.Feedback { + return .combine( + pagingFeedback(), + retryPagingFeedback() + ) + } + + static var reduce: Reducer { + return combine( + reducer, + pullback( + ColorPicker.reduce, + value: \.colorPicker, + event: \.colorPicker + ) + ) + } + + private static func reducer(state: inout State, event: Event) { + switch event { + case .startLoadingNextPage: + state.status = .paging + case .response(let batch): + state.batch = batch + state.movies += batch.results + state.status = .loadedPage + case .failed(let error): + state.status = .error(error) + case .retry: + state.status = .retry + case .picker(_): + // The beauty of state composition is that at the parent level + // we can also intercept events of the child and react to them + // The rule should be tho that we should not mutate the state of the child + // to not get conflicts + break; + } + } + + private static func pagingFeedback() -> FeedbackLoop.Feedback { + return FeedbackLoop.Feedback(skippingRepeated: { $0.nextPage }) { nextPage in + return URLSession.shared.fetchMovies(page: nextPage) + .observe(on: UIScheduler()) + .map(Event.response) + .flatMapError { error in + SignalProducer(value: Event.failed(error)) + } + } + } + + private static func retryPagingFeedback() -> FeedbackLoop.Feedback { + return .init(skippingRepeated: { $0.retryPage }) { nextPage in + URLSession.shared.fetchMovies(page: nextPage) + .observe(on: UIScheduler()) + .map(Event.response) + .flatMapError { error in + SignalProducer(value: Event.failed(error)) + } + } + } +} diff --git a/Example/SingleStoreExample/Movies/MoviesView.swift b/Example/SingleStoreExample/Movies/MoviesView.swift new file mode 100644 index 0000000..ca80255 --- /dev/null +++ b/Example/SingleStoreExample/Movies/MoviesView.swift @@ -0,0 +1,93 @@ +import UIKit +import ReactiveFeedback + +final class MoviesViewController: ContainerViewController { + private let store: Store + + init(store: Store) { + self.store = store + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + store.state.producer.startWithValues(contentView.render) + contentView.didSelectItem.action = { [unowned self] movie in + let nc = self.navigationController! + let vc = ColorPickerViewController( + store: self.store.view( + value: \.colorPicker, + event: Movies.Event.picker + ) + ) + nc.pushViewController(vc, animated: true) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class MoviesView: UICollectionView, NibLoadable, UICollectionViewDelegateFlowLayout { + public let didSelectItem = CommandWith() + private let adapter = ArrayCollectionViewDataSource() + private let loadNext = Command() + private var isReloadInProgress = false + + override func awakeFromNib() { + super.awakeFromNib() + self.dataSource = adapter + register(MovieCell.nib, forCellWithReuseIdentifier: "\(MovieCell.self)") + adapter.cellFactory = { (collectionView, indexPath, item) -> UICollectionViewCell in + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: "\(MovieCell.self)", + for: indexPath + ) as! MovieCell + cell.configure(with: item) + + return cell + } + self.delegate = self + } + + func render(context: Context) { + adapter.update(with: context.movies) + backgroundColor = context.backgroundColor + loadNext.action = { + context.send(event: .startLoadingNextPage) + } + reloadData() + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let movie = adapter.items[indexPath.row] + didSelectItem.action(movie) + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if indexPath.row == adapter.items.count - 1 { + // Because feedbacks are now synchronous + // Dispatching some events may cause a dead lock + // because collection view calls it again during reload data + DispatchQueue.main.async { + self.loadNext.action() + } + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let flowLayout = collectionViewLayout as! UICollectionViewFlowLayout + let spacing = flowLayout.minimumInteritemSpacing + let width = (collectionView.bounds.width - 3 * spacing) / 3 + + return CGSize( + width: width, + height: width * 1.5 + ) + } +} diff --git a/Example/SingleStoreExample/Movies/MoviesView.xib b/Example/SingleStoreExample/Movies/MoviesView.xib new file mode 100644 index 0000000..4c4fb2a --- /dev/null +++ b/Example/SingleStoreExample/Movies/MoviesView.xib @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/SingleStoreExample/RootViewController.swift b/Example/SingleStoreExample/RootViewController.swift new file mode 100644 index 0000000..33eac48 --- /dev/null +++ b/Example/SingleStoreExample/RootViewController.swift @@ -0,0 +1,129 @@ +import ReactiveFeedback +import ReactiveSwift +import UIKit + +final class RootViewController: UITabBarController { + private let store: Store + + init() { + let appReducer: Reducer = combine( + pullback( + Counter.reduce, + value: \.counter, + event: \.counter + ), + pullback( + Movies.reduce, + value: \.movies, + event: \.movies + ) + ) + + let appFeedbacks: FeedbackLoop.Feedback = FeedbackLoop.Feedback.combine( + FeedbackLoop.Feedback.pullback( + feedback: Movies.feedback, + value: \.movies, + event: Event.movies + ) + ) + store = Store( + initial: State(), + reducer: appReducer, + feedbacks: [appFeedbacks] + ) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + let counterVC = CounterViewController( + store: store.view( + value: \.counter, + event: Event.counter + ) + ) + let moviesVC = MoviesViewController( + store: store.view( + value: \.movies, + event: Event.movies + ) + ) + viewControllers = [ + UINavigationController(rootViewController: counterVC), + UINavigationController(rootViewController: moviesVC), + UINavigationController(rootViewController: TextInputViewController()) + ] + if #available(iOS 11.0, *) { + counterVC.navigationItem.largeTitleDisplayMode = .always + } + counterVC.title = "Counter" + counterVC.tabBarItem = UITabBarItem( + title: "Counter", + image: UIImage(named: "counter"), + selectedImage: UIImage(named: "counter") + ) + if #available(iOS 11.0, *) { + moviesVC.navigationItem.largeTitleDisplayMode = .always + } + moviesVC.title = "Movies" + moviesVC.tabBarItem = UITabBarItem( + title: "Movies", + image: UIImage(named: "movie"), + selectedImage: UIImage(named: "movie") + ) + } +} + +open class ContainerViewController: UIViewController { + private(set) lazy var contentView = Content.loadFromNib() + + init() { + super.init(nibName: nil, bundle: nil) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override open func loadView() { + view = contentView + } +} + +struct State { + var counter = Counter.State() + var movies = Movies.State() +} + +enum Event { + case counter(Counter.Event) + case movies(Movies.Event) + + // This can be done with CasePaths + // https://github.com/pointfreeco/swift-case-paths + var counter: Counter.Event? { + get { + guard case let .counter(value) = self else { return nil } + return value + } + set { + guard case .counter = self, let newValue = newValue else { return } + self = .counter(newValue) + } + } + + var movies: Movies.Event? { + get { + guard case let .movies(value) = self else { return nil } + return value + } + set { + guard case .movies = self, let newValue = newValue else { return } + self = .movies(newValue) + } + } +} diff --git a/Example/ViewController.swift b/Example/ViewController.swift deleted file mode 100644 index e78d84e..0000000 --- a/Example/ViewController.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ViewController.swift -// ReactiveFeedback -// -// Created by sergdort on 28/08/2017. -// Copyright © 2017 sergdort. All rights reserved. -// - -import UIKit -import ReactiveSwift -import ReactiveCocoa -import ReactiveFeedback - -enum Event { - case increment - case decrement -} - -class ViewController: UIViewController { - @IBOutlet weak var plusButton: UIButton! - @IBOutlet weak var minusButton: UIButton! - @IBOutlet weak var label: UILabel! - - private var incrementSignal: Signal { - return plusButton.reactive.controlEvents(.touchUpInside).map { _ in } - } - - private var decrementSignal: Signal { - return minusButton.reactive.controlEvents(.touchUpInside).map { _ in } - } - - lazy var viewModel: ViewModel = { - return ViewModel(increment: self.incrementSignal, - decrement: self.decrementSignal) - }() - - override func viewDidLoad() { - super.viewDidLoad() - label.reactive.text <~ viewModel.counter - } -} - -final class ViewModel { - private let state: Property - let counter: Property - - init(increment: Signal, decrement: Signal) { - - let incrementFeedback = Feedback(predicate: { $0 < 10}) { _ in - increment.map { _ in Event.increment } - } - - let decrementFeedback = Feedback(predicate: { $0 > -10 }) { _ in - decrement.map { _ in Event.decrement } - } - - self.state = Property(initial: 0, - reduce: ViewModel.reduce, - feedbacks: incrementFeedback, decrementFeedback) - - self.counter = state.map(String.init) - } -} - -extension ViewModel { - static func reduce(state: Int, event: Event) -> Int { - switch event { - case .increment: - return state + 1 - case .decrement: - return state - 1 - } - } -} diff --git a/ReactiveFeedback.xcodeproj/project.pbxproj b/ReactiveFeedback.xcodeproj/project.pbxproj index abbc646..c41d3b3 100644 --- a/ReactiveFeedback.xcodeproj/project.pbxproj +++ b/ReactiveFeedback.xcodeproj/project.pbxproj @@ -10,12 +10,33 @@ 250B70DF23FC441300848429 /* FeedbackLoopSystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 250B70DE23FC441300848429 /* FeedbackLoopSystemTests.swift */; }; 250B70E023FC441300848429 /* FeedbackLoopSystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 250B70DE23FC441300848429 /* FeedbackLoopSystemTests.swift */; }; 250B70E123FC441300848429 /* FeedbackLoopSystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 250B70DE23FC441300848429 /* FeedbackLoopSystemTests.swift */; }; + 251ACF2E24141B4000C600CB /* PaginationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251ACF2C24141B4000C600CB /* PaginationViewController.swift */; }; + 251ACF2F24141B4000C600CB /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251ACF2D24141B4000C600CB /* ViewController.swift */; }; 25E1D2211F5493D000D90192 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E1D2201F5493D000D90192 /* AppDelegate.swift */; }; - 25E1D2231F5493D000D90192 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E1D2221F5493D000D90192 /* ViewController.swift */; }; 25E1D2261F5493D000D90192 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 25E1D2241F5493D000D90192 /* Main.storyboard */; }; 25E1D2281F5493D000D90192 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 25E1D2271F5493D000D90192 /* Assets.xcassets */; }; 25E1D22B1F5493D000D90192 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 25E1D2291F5493D000D90192 /* LaunchScreen.storyboard */; }; - 25E1D2381F56091A00D90192 /* PaginationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E1D2371F56091A00D90192 /* PaginationViewController.swift */; }; + 5810F032239EB2C400708F62 /* MoviesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5810F031239EB2C400708F62 /* MoviesView.swift */; }; + 5810F034239EB2D200708F62 /* MoviesView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5810F033239EB2D200708F62 /* MoviesView.xib */; }; + 5810F036239EB31900708F62 /* MovieCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5810F035239EB31900708F62 /* MovieCell.xib */; }; + 5810F038239EB5A700708F62 /* MovieCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5810F037239EB5A700708F62 /* MovieCell.swift */; }; + 5810F03A239EBEA100708F62 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5810F039239EBEA100708F62 /* RootViewController.swift */; }; + 5810F03D239FA9F200708F62 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5810F03C239FA9F200708F62 /* ColorPicker.swift */; }; + 5810F03F239FAB0200708F62 /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5810F03E239FAB0200708F62 /* ColorPickerView.swift */; }; + 5810F041239FAB8B00708F62 /* ColorPickerView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5810F040239FAB8B00708F62 /* ColorPickerView.xib */; }; + 585CD87B239E6A39004BE9CC /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87A239E6A39004BE9CC /* Reducer.swift */; }; + 585CD87C239E6A3E004BE9CC /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87A239E6A39004BE9CC /* Reducer.swift */; }; + 585CD87D239E6A3E004BE9CC /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87A239E6A39004BE9CC /* Reducer.swift */; }; + 585CD87F239E6A98004BE9CC /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87E239E6A98004BE9CC /* Context.swift */; }; + 585CD880239E6A9A004BE9CC /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87E239E6A98004BE9CC /* Context.swift */; }; + 585CD881239E6A9B004BE9CC /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD87E239E6A98004BE9CC /* Context.swift */; }; + 585CD883239E6AF1004BE9CC /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD882239E6AF1004BE9CC /* Store.swift */; }; + 585CD884239E6AF1004BE9CC /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD882239E6AF1004BE9CC /* Store.swift */; }; + 585CD885239E6AF1004BE9CC /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD882239E6AF1004BE9CC /* Store.swift */; }; + 585CD88A239E9077004BE9CC /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD889239E9077004BE9CC /* Counter.swift */; }; + 585CD88C239E90EB004BE9CC /* CounterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD88B239E90EB004BE9CC /* CounterView.swift */; }; + 585CD88E239E9104004BE9CC /* CounterView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 585CD88D239E9104004BE9CC /* CounterView.xib */; }; + 585CD891239EA8E6004BE9CC /* Movies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD890239EA8E6004BE9CC /* Movies.swift */; }; 5898B6D11F97ADDD005EEAEC /* SystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5898B6D01F97ADDD005EEAEC /* SystemTests.swift */; }; 656A9C9323D0813500EFB2F8 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */; }; 656A9C9423D0813500EFB2F8 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */; }; @@ -44,8 +65,6 @@ 65F8C2D0218378F500924657 /* ReactiveFeedback.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65F8C26B218371A800924657 /* ReactiveFeedback.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD5D42C1F97375E00E6AE5A /* Property+System.swift */; }; 9AE181BB1F95A71B00A07551 /* ReactiveFeedback.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25CC87AE1F92855300A6EBFC /* ReactiveFeedback.framework */; }; - 9AE9563C2186341B005A8C69 /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AE9563921863415005A8C69 /* Kingfisher.framework */; }; - 9AE9563D2186341B005A8C69 /* Kingfisher.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9AE9563921863415005A8C69 /* Kingfisher.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9AE9563E2186341B005A8C69 /* ReactiveCocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AE9563B21863415005A8C69 /* ReactiveCocoa.framework */; }; 9AE9563F2186341B005A8C69 /* ReactiveCocoa.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9AE9563B21863415005A8C69 /* ReactiveCocoa.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9AE956402186341B005A8C69 /* ReactiveSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AE9563821863414005A8C69 /* ReactiveSwift.framework */; }; @@ -100,7 +119,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 9AE9563D2186341B005A8C69 /* Kingfisher.framework in Embed Frameworks */, 9AE9563F2186341B005A8C69 /* ReactiveCocoa.framework in Embed Frameworks */, 65F8C2D0218378F500924657 /* ReactiveFeedback.framework in Embed Frameworks */, 9AE956412186341B005A8C69 /* ReactiveSwift.framework in Embed Frameworks */, @@ -139,16 +157,31 @@ /* Begin PBXFileReference section */ 250B70DE23FC441300848429 /* FeedbackLoopSystemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackLoopSystemTests.swift; sourceTree = ""; }; + 251ACF2C24141B4000C600CB /* PaginationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewController.swift; sourceTree = ""; }; + 251ACF2D24141B4000C600CB /* ViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 25CC87AE1F92855300A6EBFC /* ReactiveFeedback.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReactiveFeedback.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 25CC87B11F92855300A6EBFC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25E1D21D1F5493D000D90192 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 25E1D2201F5493D000D90192 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 25E1D2221F5493D000D90192 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 25E1D2251F5493D000D90192 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 25E1D2271F5493D000D90192 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 25E1D22A1F5493D000D90192 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 25E1D22C1F5493D000D90192 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 25E1D2371F56091A00D90192 /* PaginationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewController.swift; sourceTree = ""; }; + 5810F031239EB2C400708F62 /* MoviesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesView.swift; sourceTree = ""; }; + 5810F033239EB2D200708F62 /* MoviesView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MoviesView.xib; sourceTree = ""; }; + 5810F035239EB31900708F62 /* MovieCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MovieCell.xib; sourceTree = ""; }; + 5810F037239EB5A700708F62 /* MovieCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieCell.swift; sourceTree = ""; }; + 5810F039239EBEA100708F62 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; + 5810F03C239FA9F200708F62 /* ColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = ""; }; + 5810F03E239FAB0200708F62 /* ColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; + 5810F040239FAB8B00708F62 /* ColorPickerView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ColorPickerView.xib; sourceTree = ""; }; + 585CD87A239E6A39004BE9CC /* Reducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; + 585CD87E239E6A98004BE9CC /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = ""; }; + 585CD882239E6AF1004BE9CC /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 585CD889239E9077004BE9CC /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; + 585CD88B239E90EB004BE9CC /* CounterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterView.swift; sourceTree = ""; }; + 585CD88D239E9104004BE9CC /* CounterView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CounterView.xib; sourceTree = ""; }; + 585CD890239EA8E6004BE9CC /* Movies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movies.swift; sourceTree = ""; }; 587F0720201F647400ACD219 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 5898B6D01F97ADDD005EEAEC /* SystemTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemTests.swift; sourceTree = ""; }; 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackLoop.swift; sourceTree = ""; }; @@ -187,7 +220,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9AE9563C2186341B005A8C69 /* Kingfisher.framework in Frameworks */, 9AE9563E2186341B005A8C69 /* ReactiveCocoa.framework in Frameworks */, 65F8C2CF218378F500924657 /* ReactiveFeedback.framework in Frameworks */, 9AE956402186341B005A8C69 /* ReactiveSwift.framework in Frameworks */, @@ -252,6 +284,9 @@ 25CC87B11F92855300A6EBFC /* Info.plist */, 65761B2523CF20EF004D5506 /* Floodgate.swift */, 65761B2D23CF4CA1004D5506 /* NSLock+Extensions.swift */, + 585CD87A239E6A39004BE9CC /* Reducer.swift */, + 585CD87E239E6A98004BE9CC /* Context.swift */, + 585CD882239E6AF1004BE9CC /* Store.swift */, ); path = ReactiveFeedback; sourceTree = ""; @@ -286,10 +321,9 @@ 25E1D21F1F5493D000D90192 /* Example */ = { isa = PBXGroup; children = ( + 585CD88F239EA8B0004BE9CC /* MultiStoreExample */, + 585CD886239E904E004BE9CC /* SingleStoreExample */, 25E1D2201F5493D000D90192 /* AppDelegate.swift */, - 25E1D2221F5493D000D90192 /* ViewController.swift */, - 25E1D2371F56091A00D90192 /* PaginationViewController.swift */, - 65761B3123CF677F004D5506 /* TextInputViewController.swift */, 25E1D2241F5493D000D90192 /* Main.storyboard */, 25E1D2271F5493D000D90192 /* Assets.xcassets */, 25E1D2291F5493D000D90192 /* LaunchScreen.storyboard */, @@ -298,6 +332,59 @@ path = Example; sourceTree = ""; }; + 5810F03B239FA9E000708F62 /* ColorPicker */ = { + isa = PBXGroup; + children = ( + 5810F03C239FA9F200708F62 /* ColorPicker.swift */, + 5810F03E239FAB0200708F62 /* ColorPickerView.swift */, + 5810F040239FAB8B00708F62 /* ColorPickerView.xib */, + ); + path = ColorPicker; + sourceTree = ""; + }; + 585CD886239E904E004BE9CC /* SingleStoreExample */ = { + isa = PBXGroup; + children = ( + 5810F03B239FA9E000708F62 /* ColorPicker */, + 585CD888239E9069004BE9CC /* Movies */, + 585CD887239E905E004BE9CC /* Counter */, + 5810F039239EBEA100708F62 /* RootViewController.swift */, + ); + path = SingleStoreExample; + sourceTree = ""; + }; + 585CD887239E905E004BE9CC /* Counter */ = { + isa = PBXGroup; + children = ( + 585CD889239E9077004BE9CC /* Counter.swift */, + 585CD88B239E90EB004BE9CC /* CounterView.swift */, + 585CD88D239E9104004BE9CC /* CounterView.xib */, + ); + path = Counter; + sourceTree = ""; + }; + 585CD888239E9069004BE9CC /* Movies */ = { + isa = PBXGroup; + children = ( + 585CD890239EA8E6004BE9CC /* Movies.swift */, + 5810F031239EB2C400708F62 /* MoviesView.swift */, + 5810F037239EB5A700708F62 /* MovieCell.swift */, + 5810F033239EB2D200708F62 /* MoviesView.xib */, + 5810F035239EB31900708F62 /* MovieCell.xib */, + ); + path = Movies; + sourceTree = ""; + }; + 585CD88F239EA8B0004BE9CC /* MultiStoreExample */ = { + isa = PBXGroup; + children = ( + 251ACF2C24141B4000C600CB /* PaginationViewController.swift */, + 251ACF2D24141B4000C600CB /* ViewController.swift */, + 65761B3123CF677F004D5506 /* TextInputViewController.swift */, + ); + path = MultiStoreExample; + sourceTree = ""; + }; 9AE181B71F95A71B00A07551 /* ReactiveFeedbackTests */ = { isa = PBXGroup; children = ( @@ -545,8 +632,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 585CD88E239E9104004BE9CC /* CounterView.xib in Resources */, + 5810F034239EB2D200708F62 /* MoviesView.xib in Resources */, 9AFA21261F9511A5001DBF7C /* ReactiveFeedback.podspec in Resources */, 25E1D22B1F5493D000D90192 /* LaunchScreen.storyboard in Resources */, + 5810F041239FAB8B00708F62 /* ColorPickerView.xib in Resources */, + 5810F036239EB31900708F62 /* MovieCell.xib in Resources */, 25E1D2281F5493D000D90192 /* Assets.xcassets in Resources */, 25E1D2261F5493D000D90192 /* Main.storyboard in Resources */, ); @@ -597,6 +688,9 @@ A9509BE4551098F4A5503820 /* Feedback.swift in Sources */, A950943401765BB90FA846B2 /* SignalProducer+System.swift in Sources */, 65761B2E23CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, + 585CD87B239E6A39004BE9CC /* Reducer.swift in Sources */, + 585CD883239E6AF1004BE9CC /* Store.swift in Sources */, + 585CD87F239E6A98004BE9CC /* Context.swift in Sources */, 9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */, 656A9C9323D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, 656A9C9723D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, @@ -609,9 +703,17 @@ buildActionMask = 2147483647; files = ( 65761B3223CF677F004D5506 /* TextInputViewController.swift in Sources */, - 25E1D2231F5493D000D90192 /* ViewController.swift in Sources */, - 25E1D2381F56091A00D90192 /* PaginationViewController.swift in Sources */, + 585CD88A239E9077004BE9CC /* Counter.swift in Sources */, + 5810F03A239EBEA100708F62 /* RootViewController.swift in Sources */, + 5810F038239EB5A700708F62 /* MovieCell.swift in Sources */, + 251ACF2E24141B4000C600CB /* PaginationViewController.swift in Sources */, + 585CD891239EA8E6004BE9CC /* Movies.swift in Sources */, + 251ACF2F24141B4000C600CB /* ViewController.swift in Sources */, + 5810F032239EB2C400708F62 /* MoviesView.swift in Sources */, + 5810F03F239FAB0200708F62 /* ColorPickerView.swift in Sources */, + 5810F03D239FA9F200708F62 /* ColorPicker.swift in Sources */, 25E1D2211F5493D000D90192 /* AppDelegate.swift in Sources */, + 585CD88C239E90EB004BE9CC /* CounterView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -622,6 +724,9 @@ 65F8C260218371A800924657 /* Feedback.swift in Sources */, 65F8C261218371A800924657 /* SignalProducer+System.swift in Sources */, 65761B2F23CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, + 585CD87C239E6A3E004BE9CC /* Reducer.swift in Sources */, + 585CD884239E6AF1004BE9CC /* Store.swift in Sources */, + 585CD880239E6A9A004BE9CC /* Context.swift in Sources */, 65F8C262218371A800924657 /* Property+System.swift in Sources */, 656A9C9423D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, 656A9C9823D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, @@ -636,6 +741,9 @@ 65F8C26F218371AC00924657 /* Feedback.swift in Sources */, 65F8C270218371AC00924657 /* SignalProducer+System.swift in Sources */, 65761B3023CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, + 585CD87D239E6A3E004BE9CC /* Reducer.swift in Sources */, + 585CD885239E6AF1004BE9CC /* Store.swift in Sources */, + 585CD881239E6A9B004BE9CC /* Context.swift in Sources */, 65F8C271218371AC00924657 /* Property+System.swift in Sources */, 656A9C9523D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, 656A9C9923D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, @@ -883,7 +991,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.Example; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -902,7 +1010,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.Example; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; }; name = Release; }; diff --git a/ReactiveFeedback.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/ReactiveFeedback.xcodeproj/xcshareddata/xcschemes/Example.xcscheme index defa50a..77c9022 100644 --- a/ReactiveFeedback.xcodeproj/xcshareddata/xcschemes/Example.xcscheme +++ b/ReactiveFeedback.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -72,7 +72,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "D1ED2D341AD2D09F00CFC3EB" BuildableName = "Kingfisher.framework" - BlueprintName = "Kingfisher-iOS" + BlueprintName = "Kingfisher" ReferencedContainer = "container:Carthage/Checkouts/Kingfisher/Kingfisher.xcodeproj"> @@ -97,8 +97,6 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + + - - - - diff --git a/ReactiveFeedback/Context.swift b/ReactiveFeedback/Context.swift new file mode 100644 index 0000000..fa424de --- /dev/null +++ b/ReactiveFeedback/Context.swift @@ -0,0 +1,33 @@ +@dynamicMemberLookup +public struct Context { + private let state: State + private let forward: (Event) -> Void + + public init( + state: State, + forward: @escaping (Event) -> Void + ) { + self.state = state + self.forward = forward + } + + public subscript(dynamicMember keyPath: KeyPath) -> U { + return state[keyPath: keyPath] + } + + public func send(event: Event) { + forward(event) + } + + public func view( + value: KeyPath, + event: @escaping (LocalEvent) -> Event + ) -> Context { + return Context( + state: state[keyPath: value], + forward: { localEvent in + self.forward(event(localEvent)) + } + ) + } +} diff --git a/ReactiveFeedback/FeedbackEventConsumer.swift b/ReactiveFeedback/FeedbackEventConsumer.swift index 7fc858f..c23a1f8 100644 --- a/ReactiveFeedback/FeedbackEventConsumer.swift +++ b/ReactiveFeedback/FeedbackEventConsumer.swift @@ -1,14 +1,6 @@ import Foundation public class FeedbackEventConsumer { - struct Token: Equatable { - let value: UUID - - init() { - value = UUID() - } - } - func process(_ event: Event, for token: Token) { fatalError("This is an abstract class. You must subclass this and provide your own implementation") } @@ -17,3 +9,36 @@ public class FeedbackEventConsumer { fatalError("This is an abstract class. You must subclass this and provide your own implementation") } } + +extension FeedbackEventConsumer { + func pullback(_ f: @escaping (LocalEvent) -> Event) -> FeedbackEventConsumer { + return PullBackConsumer(upstream: self, pull: f) + } +} + +final class PullBackConsumer: FeedbackEventConsumer { + private let upstream: FeedbackEventConsumer + private let pull: (LocalEvent) -> Event + + init(upstream: FeedbackEventConsumer, pull: @escaping (LocalEvent) -> Event) { + self.pull = pull + self.upstream = upstream + super.init() + } + + override func process(_ event: LocalEvent, for token: Token) { + self.upstream.process(pull(event), for: token) + } + + override func dequeueAllEvents(for token: Token) { + self.upstream.dequeueAllEvents(for: token) + } +} + +struct Token: Equatable { + let value: UUID + + init() { + value = UUID() + } +} diff --git a/ReactiveFeedback/FeedbackLoop.swift b/ReactiveFeedback/FeedbackLoop.swift index 35553f8..466d6c4 100644 --- a/ReactiveFeedback/FeedbackLoop.swift +++ b/ReactiveFeedback/FeedbackLoop.swift @@ -178,5 +178,37 @@ extension FeedbackLoop { ) where Effect.Value == Event, Effect.Error == Never { self.init(compacting: { $0 }, effects: effects) } + + public static var input: (feedback: Feedback, observer: (Event) -> Void) { + let pipe = Signal.pipe() + let feedback = Feedback.custom { (state, consumer) -> Disposable in + pipe.output.producer.enqueue(to: consumer).start() + } + return (feedback, pipe.input.send) + } + + public static func pullback( + feedback: FeedbackLoop.Feedback, + value: KeyPath, + event: @escaping (LocalEvent) -> Event + ) -> Feedback { + return Feedback.custom { (state, consumer) -> Disposable in + return feedback.events( + state.map(value), + consumer.pullback(event) + ) + } + } + + public static func combine(_ feedbacks: FeedbackLoop.Feedback...) -> Feedback { + return .custom { (state, consumer) -> Disposable in + return feedbacks.map { (feedback) in + feedback.events(state, consumer) + } + .reduce(into: CompositeDisposable()) { (composite, disposable) in + composite += disposable + } + } + } } } diff --git a/ReactiveFeedback/Floodgate.swift b/ReactiveFeedback/Floodgate.swift index 0ef23f3..54a8caa 100644 --- a/ReactiveFeedback/Floodgate.swift +++ b/ReactiveFeedback/Floodgate.swift @@ -86,7 +86,7 @@ final class Floodgate: FeedbackEventConsumer { extension SignalProducer where Error == Never { public func enqueue(to consumer: FeedbackEventConsumer) -> SignalProducer { SignalProducer { observer, lifetime in - let token = FeedbackEventConsumer.Token() + let token = Token() lifetime += self.startWithValues { event in consumer.process(event, for: token) diff --git a/ReactiveFeedback/Reducer.swift b/ReactiveFeedback/Reducer.swift new file mode 100644 index 0000000..4741fbe --- /dev/null +++ b/ReactiveFeedback/Reducer.swift @@ -0,0 +1,24 @@ +public typealias Reducer = (inout State, Event) -> Void + +public func combine( + _ reducers: Reducer... +) -> Reducer { + return { state, event in + for reducer in reducers { + reducer(&state, event) + } + } +} + +public func pullback( + _ reducer: @escaping Reducer, + value: WritableKeyPath, + event: WritableKeyPath +) -> Reducer { + return { globalState, globalEvent in + guard let localAction = globalEvent[keyPath: event] else { + return + } + reducer(&globalState[keyPath: value], localAction) + } +} diff --git a/ReactiveFeedback/Store.swift b/ReactiveFeedback/Store.swift new file mode 100644 index 0000000..a4eea67 --- /dev/null +++ b/ReactiveFeedback/Store.swift @@ -0,0 +1,60 @@ +import ReactiveSwift + +open class Store { + public let state: Property> + + private let input = FeedbackLoop.Feedback.input + private let forward: (Event) -> Void + + public init( + initial: State, + reducer: @escaping Reducer, + feedbacks: [FeedbackLoop.Feedback] + ) { + self.forward = { _ in } + self.state = Property( + initial: Context(state: initial, forward: self.input.observer), + then: SignalProducer.feedbackLoop( + initial: initial, + reduce: reducer, + feedbacks: feedbacks.appending(input.feedback) + ) + .map { [input] state in + Context(state: state, forward: input.observer) + } + .skip(first: 1) + ) + } + + private init(state: Property>, send: @escaping (Event) -> Void) { + self.state = state + self.forward = send + } + + open func send(event: Event) { + input.observer(event) + forward(event) + } + + public func view( + value: KeyPath, + event: @escaping (LocalEvent) -> Event + ) -> Store { + return Store( + state: state.map { $0.view(value: value, event: event) }, + send: { localEvent in + self.send(event: event(localEvent)) + } + ) + } +} + +fileprivate extension Array { + func appending(_ element: Element) -> [Element] { + var copy = self + + copy.append(element) + + return copy + } +}