diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e3b984 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +sourcekitten-output.json +docs/ +/.build +/Packages +/*.xcodeproj +**/xcuserdata +**/xcshareddata +Pods/ +Carthage/ +Examples/**/Podfile.lock diff --git a/AlertReactor.podspec b/AlertReactor.podspec new file mode 100644 index 0000000..95bfb1e --- /dev/null +++ b/AlertReactor.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = "AlertReactor" + s.version = "0.1.0" + s.summary = "ReactorKit extension for UIAlertController" + s.homepage = "https://github.com/devxoul/AlertReactor" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "Suyeol Jeon" => "devxoul@gmail.com" } + s.source = { :git => "https://github.com/devxoul/AlertReactor.git", + :tag => s.version.to_s } + s.source_files = "Sources/**/*.{swift,h,m}" + s.frameworks = "Foundation" + s.dependency "ReactorKit" + + s.ios.deployment_target = "8.0" + s.osx.deployment_target = "10.11" + s.tvos.deployment_target = "9.0" + s.watchos.deployment_target = "2.0" + + s.pod_target_xcconfig = { + "SWIFT_VERSION" => "3.1" + } +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93630c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Suyeol Jeon (xoul.kr) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..098d1ed --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:3.1 + +import Foundation +import PackageDescription + +var dependencies: [Package.Dependency] = [ + .Package(url: "https://github.com/ReactiveX/RxSwift.git", majorVersion: 3), + .Package(url: "https://github.com/ReactorKit/ReactorKit.git", majorVersion: 0), +] + +let isTest = ProcessInfo.processInfo.environment["TEST"] == "1" +if isTest { + dependencies.append( + .Package(url: "https://github.com/devxoul/RxExpect.git", majorVersion: 0) + ) +} + +let package = Package( + name: "AlertReactor", + dependencies: dependencies +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ab30ba --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# AlertReactor + +![Swift](https://img.shields.io/badge/Swift-3.1-orange.svg) +[![CocoaPods](http://img.shields.io/cocoapods/v/AlertReactor.svg)](https://cocoapods.org/pods/AlertReactor) +[![Build Status](https://travis-ci.org/devxoul/AlertReactor.svg?branch=master)](https://travis-ci.org/devxoul/AlertReactor) +[![Codecov](https://img.shields.io/codecov/c/github/devxoul/AlertReactor.svg)](https://codecov.io/gh/devxoul/AlertReactor) + +ReactorKit extension for UIAlertController. It provides an elegant way to deal with an UIAlertController. Best fits for lazy-loaded alert actions. + +## Features + +* Statically typed alert actions +* Reactive and dynamic action bindings + +## At a Glance + +With AlertReactor, you can write a reactive code for alert controller. The code below displays an action sheet when `menuButton` is tapped. When an user selects an item in the action sheet, the selected menu item is converted into an action which is binded to a reactor. + +```swift +// Menu Button -> Action Sheet -> Reactor Action +menuButton.rx.tap + .flatMap { [weak self] _ -> Observable in + let reactor = UserAlertReactor() + let controller = AlertController(reactor: reactor, preferredStyle: .actionSheet) + self?.present(controller, animated: true, completion: nil) + return controller.rx.actionSelected.asObservable() + } + .map { alertAction -> Reactor.Action? in + switch action { + case .follow: return .followUser + case .unfollow: return .unfollowUser + case .block: return .blockUser + case .cancel: return nil + } + } + .filterNil() + .bind(to: reactor.action) +``` + +## Getting Started + +### 1. Defining an Alert Action + +AlertReactor provides a `AlertActionType ` protocol. This is an abstraction model of `UIAlertAction`. Create a new type conforming this protocol. This protocol requires a `title` and `style` property. + +```swift +enum UserAlertAction: AlertActionType { + case follow + case unfollow + case block + case cancel + + // required + var title: String { + case follow: return "Follow" + case unfollow: return "Unfollow" + case block: return "Block" + case cancel: return "Cancel" + } + + // optional + var style: UIAlertActionStyle { + case follow: return .default + case unfollow: return .default + case block: return .destructive + case cancel: return .cancel + } +} +``` + + +### 2. Creating an Alert Reactor + +`AlertReactor` is a reactor class. It has an action, mutation and state: + +```swift +enum Action { + case prepare +} + +enum Mutation { + case setTitle(String?) + case setMessage(String?) + case setActions([AlertAction]) +} + +struct State { + public var title: String? + public var message: String? + public var actions: [AlertAction] +} +``` + +Override this class to implement `mutate(action:)` method so that the reactor can emit mutations to change the state. Here is an example of lazy-loaded actions. + +```swift +final class UserAlertReactor: AlertReactor { + let userID: Int + + init(userID: Int) { + self.userID = userID + } + + override func mutate(action: Action) -> Observable { + return Observable.concat([ + // Initial actions + Observable.just(Mutation.setTitle("Loading...")), + Observable.just(Mutation.setActions([.block, .cancel])), + + // Call API to choose follow or unfollow + api.isFollowing(userID: userID) + .map { isFollowing -> Mutation in + if isFollowing { + return Mutation.setActions([.unfollow, .block, .cancel]) + } else { + return Mutation.setActions([.follow, .block, .cancel]) + } + } + Observable.just(Mutation.setTitle(nil)), + ]) + } +} +``` + +### 3. Using with Alert Controller + +A generic class `AlertController` is provided. You can create it with some parameters: `reactor` and `preferredStyle`. There's also a `actionSelected` control event property in a reactive extension. + +```swift +let reactor = UserAlertReactor(userID: 12345) +let controller = AlertController(reactor: reactor, preferredStyle: .actionSheet) +controller.rx.actionSelected + .subscribe(onNext: { action in + switch action { + case .follow: print("Follow user") + case .unfollow: print("Unfollow user") + case .block: print("Block user") + case .cancel: print("Cancel") + } + }) +``` + +## Installation + +```ruby +pod 'AlertReactor' +``` + +## License + +AlertReactor is under MIT license. See the [LICENSE](LICENSE) for more info. diff --git a/Sources/AlertActionType.swift b/Sources/AlertActionType.swift new file mode 100644 index 0000000..bfc9305 --- /dev/null +++ b/Sources/AlertActionType.swift @@ -0,0 +1,12 @@ +import UIKit + +public protocol AlertActionType: Equatable { + var title: String { get } + var style: UIAlertActionStyle { get } +} + +public extension AlertActionType { + var style: UIAlertActionStyle { + return .default + } +} diff --git a/Sources/AlertController.swift b/Sources/AlertController.swift new file mode 100644 index 0000000..4e7e3d8 --- /dev/null +++ b/Sources/AlertController.swift @@ -0,0 +1,101 @@ +import UIKit + +import ReactorKit +import RxCocoa +import RxSwift + + +// MARK: - AlertControllerType + +public protocol AlertControllerType: class { + associatedtype AlertAction: AlertActionType + var _actionSelectedSubject: PublishSubject { get } + func setValue(_ value: Any?, forKey key: String) +} + + +// MARK: - AlertController + +open class AlertController: UIAlertController, AlertControllerType, View { + public typealias AlertAction = A + + + // MARK: Properties + + open var disposeBag = DisposeBag() + public private(set) lazy var _actionSelectedSubject: PublishSubject = .init() + + private var _preferredStyle: UIAlertControllerStyle + open override var preferredStyle: UIAlertControllerStyle { + get { return self._preferredStyle } + set { self._preferredStyle = newValue } + } + + + // MARK: Initializing + + public init(reactor: AlertReactor? = nil, preferredStyle: UIAlertControllerStyle = .alert) { + self._preferredStyle = preferredStyle + super.init(nibName: nil, bundle: nil) + self.title = "" + self.reactor = reactor + } + + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Binding + + open func bind(reactor: AlertReactor) { + // Action + self.rx.methodInvoked(#selector(UIViewController.viewDidLoad)) + .map { _ in Reactor.Action.prepare } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state.map { $0.title } + .distinctUntilChanged { $0 == $1 } + .subscribe(onNext: { [weak self] title in + self?.title = title + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.message } + .distinctUntilChanged { $0 == $1 } + .subscribe(onNext: { [weak self] message in + self?.message = message + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.actions } + .distinctUntilChanged { old, new in + old.elementsEqual(new) { $0.title == $1.title && $0.style == $1.style } + } + .bind(to: self.rx.actions) + .disposed(by: self.disposeBag) + } +} + + +// MARK: - Reactive Extension + +extension Reactive where Base: AlertControllerType { + var actions: UIBindingObserver { + return UIBindingObserver(UIElement: self.base) { alertController, actions in + let alertActions = actions.map { action in + UIAlertAction(title: action.title, style: action.style) { [weak base = self.base] _ in + base?._actionSelectedSubject.onNext(action) + } + } + alertController.setValue(alertActions, forKey: "actions") + } + } + + var actionSelected: ControlEvent { + let source = self.base._actionSelectedSubject.asObservable() + return ControlEvent(events: source) + } +} diff --git a/Sources/AlertReactor.swift b/Sources/AlertReactor.swift new file mode 100644 index 0000000..f4c07ee --- /dev/null +++ b/Sources/AlertReactor.swift @@ -0,0 +1,63 @@ +import ReactorKit +import RxSwift + +open class AlertReactor: Reactor { + public typealias Action = _Action + public enum _Action { + case prepare + } + + public typealias Mutation = _Mutation + public enum _Mutation { + case setTitle(String?) + case setMessage(String?) + case setActions([AlertAction]) + } + + public typealias State = _State + public struct _State { + public var title: String? + public var message: String? + public var actions: [AlertAction] = [] + } + + open let initialState: State + + public init(title: String? = nil, message: String? = nil, actions: [AlertAction]? = nil) { + self.initialState = State(title: title, message: message, actions: actions ?? []) + } + + open func transform(action: Observable) -> Observable { + return action + } + + open func mutate(action: Action) -> Observable { + // override point + return .empty() + } + + open func transform(mutation: Observable) -> Observable { + return mutation + } + + open func reduce(state: State, mutation: Mutation) -> State { + var state = state + switch mutation { + case let .setTitle(title): + state.title = title + return state + + case let .setMessage(message): + state.message = message + return state + + case let .setActions(actions): + state.actions = actions + return state + } + } + + open func transform(state: Observable) -> Observable { + return state + } +} diff --git a/Tests/AlertReactorTests/AlertControllerTests.swift b/Tests/AlertReactorTests/AlertControllerTests.swift new file mode 100644 index 0000000..03224de --- /dev/null +++ b/Tests/AlertReactorTests/AlertControllerTests.swift @@ -0,0 +1,38 @@ +import XCTest +import RxExpect +import RxTest +import AlertReactor + +class AlertControllerTests: XCTestCase { + func testController() { + let reactor = AlertReactor() + reactor.stub.isEnabled = true + + let controller = AlertController(reactor: reactor) + _ = controller.view + XCTAssertEqual(controller.actions.count, 0) + + reactor.stub.state.value.actions = [.cancel] + XCTAssertEqual(controller.actions.count, 1) + XCTAssertEqual(controller.actions[0].title, "Cancel") + XCTAssertEqual(controller.actions[0].style, .cancel) + + reactor.stub.state.value.actions = [.delete, .cancel] + XCTAssertEqual(controller.actions.count, 2) + XCTAssertEqual(controller.actions[0].title, "Delete") + XCTAssertEqual(controller.actions[0].style, .destructive) + XCTAssertEqual(controller.actions[1].title, "Cancel") + XCTAssertEqual(controller.actions[1].style, .cancel) + + reactor.stub.state.value.actions = [.edit, .share, .delete, .cancel] + XCTAssertEqual(controller.actions.count, 4) + XCTAssertEqual(controller.actions[0].title, "Edit") + XCTAssertEqual(controller.actions[0].style, .default) + XCTAssertEqual(controller.actions[1].title, "Share") + XCTAssertEqual(controller.actions[1].style, .default) + XCTAssertEqual(controller.actions[2].title, "Delete") + XCTAssertEqual(controller.actions[2].style, .destructive) + XCTAssertEqual(controller.actions[3].title, "Cancel") + XCTAssertEqual(controller.actions[3].style, .cancel) + } +} diff --git a/Tests/AlertReactorTests/AlertReactorTests.swift b/Tests/AlertReactorTests/AlertReactorTests.swift new file mode 100644 index 0000000..b5d1145 --- /dev/null +++ b/Tests/AlertReactorTests/AlertReactorTests.swift @@ -0,0 +1,23 @@ +import XCTest +import RxExpect +import RxTest +import AlertReactor + +class AlertReactorTests: XCTestCase { + func testReactor() { + RxExpect { test in + let reactor = MyAlertReactor(scheduler: test.scheduler) + test.retain(reactor) + test.input(reactor.action, [next(100, .prepare)]) + test.assert(reactor.state.map { $0.actions }) + .filterNext() + .equal([ + [], + [.cancel], + [.edit, .cancel], + [.edit, .delete, .cancel], + [.edit, .share, .delete, .cancel], + ]) + } + } +} diff --git a/Tests/AlertReactorTests/Fixtures.swift b/Tests/AlertReactorTests/Fixtures.swift new file mode 100644 index 0000000..4df4ca1 --- /dev/null +++ b/Tests/AlertReactorTests/Fixtures.swift @@ -0,0 +1,49 @@ +import UIKit +import RxSwift +import AlertReactor + +enum MyAlertAction: AlertActionType { + case edit + case share + case delete + case cancel + + var title: String { + switch self { + case .edit: return "Edit" + case .share: return "Share" + case .delete: return "Delete" + case .cancel: return "Cancel" + } + } + + var style: UIAlertActionStyle { + switch self { + case .edit: return .default + case .share: return .default + case .delete: return .destructive + case .cancel: return .cancel + } + } +} + +final class MyAlertReactor: AlertReactor { + let scheduler: SchedulerType + + init(scheduler: SchedulerType) { + self.scheduler = scheduler + super.init() + } + + override func mutate(action: Action) -> Observable { + switch action { + case .prepare: + return Observable.concat([ + Observable.just(.setActions([.cancel])), + Observable.just(.setActions([.edit, .cancel])).delay(100, scheduler: self.scheduler), + Observable.just(.setActions([.edit, .delete, .cancel])).delay(100, scheduler: self.scheduler), + Observable.just(.setActions([.edit, .share, .delete, .cancel])).delay(100, scheduler: self.scheduler), + ]) + } + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..6cd8919 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +ignore: + - "Tests/" + +coverage: + status: + project: no + patch: no + changes: no