diff --git a/.gitignore b/.gitignore index 8615121..60089aa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,6 @@ DerivedData # Carthage # # Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts +Carthage/Checkouts Carthage/Build diff --git a/Documents/4_pincode.md b/Documents/4_pincode.md new file mode 100644 index 0000000..5c88366 --- /dev/null +++ b/Documents/4_pincode.md @@ -0,0 +1,53 @@ +#### 4. Pincode 🔐 + +While for some forms like the one presented in [Example 1. Form 🐥](1_form.md) the validity of the input as well as the result as a whole rely only on the current input, there are also more complicated situations in which previous values are just as important as the current ones. + +Take, for example, a form to change your pincode. First, you'll have to input your current pincode to authorize yourself, then you'll enter a new pincode that also needs to be confirmed by entering the same new pincode a second time. Whats more, you are not allowed to use the same pincode as you had before. + +In this example, the most interesting bits are: + +1. The use of `Action` to generate a stream of inputs. +2. The implementation of a state machine in `PincodeViewModel`. + +Let's start with the `Action` first: + +`PincodeViewModel` has a `MutableProperty` called `input` which will be bound to the input by the `PincodeViewController` (In this example, a simple `UITextField` is used for the input instead of fancy number buttons). +`enterCodeAction` is an `Action` that, invoked by tap on the button, will send the current value of `input` once. + +```swift +enterCodeAction = Action(state: input, enabledIf: inputValid) { state, _ in + SignalProducer(value: state) +} +``` + +Thus, `enterCodeAction.values` is a `Signal` on which for each tap on the buttn, the current input value is sent. + + +The second part is to turn the stream of input events into the the current state. To achieve this, the `scan` operator is used on the signal of input values. Maybe you are already familiar with the [`reduce`](https://developer.apple.com/documentation/swift/array/2298686-reduce) operator in the swift standard library which combines all values of a sequence, like an array, into a final accumluated value. In the context of ReactiveCocoa, `reduce` works the same way, just on signals. `scan` works almost the same as `reduce`, but while `reduce` produces only one final accumulated value, `scan` also produces each intermediary result. +This behavior of `scan` is important since we are not just interested in the final state of the form, but in each state after a new input. + +```swift +let state = enterCodeAction.values.scan(State.initial) { (currentState, pincode) in + let nextState: State + switch currentState { + case .initial: + // Initially, the pin code entered has to match the current pincode + nextState = (pincode == currentPincode) ? + .oldPincodeCorrect(message: nil) : + .error(reason: "Wrong pincode") + ... + ... + default: + // After the new pincode was confirmed or an error, nothing changes anymore + nextState = currentState + } + return nextState +} +``` + +With each new value on the `enterCodeAction.values` signal, the code in `scan` is executed once with the `currentState` (started with the initial state) and the current `pincode` that was entered. Based on these two values, the next state has to be generated and returned. E.g. in the initial state, the input has to match the current pincode. If thats the case, the next step is to input a new input. Otherwise, the next state ist the error state (with an according message that can be shown to the user). +Cases for all the other possible states are implemented to handle all other transitions in the state machine. + +What remains is to wire up the viewmodel to the interface in `PincodeViewController`. + +--- \ No newline at end of file diff --git a/RACNest.xcodeproj/project.pbxproj b/RACNest.xcodeproj/project.pbxproj index 495ce5f..ec3e195 100644 --- a/RACNest.xcodeproj/project.pbxproj +++ b/RACNest.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 5BC29BC91F62B970007D2AA7 /* PincodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC29BC81F62B970007D2AA7 /* PincodeViewController.swift */; }; + 5BC29BCC1F62B992007D2AA7 /* PincodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC29BCB1F62B992007D2AA7 /* PincodeViewModel.swift */; }; B25D051B1EA2CCBD0039D323 /* ReactiveSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B25D051A1EA2CCBD0039D323 /* ReactiveSwift.framework */; }; C72D32E21C470F5300F88B11 /* RACNestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72D32E11C470F5300F88B11 /* RACNestTests.swift */; }; C72D32F81C470FE000F88B11 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72D32ED1C470FE000F88B11 /* AppDelegate.swift */; }; @@ -43,6 +45,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 5BC29BC81F62B970007D2AA7 /* PincodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PincodeViewController.swift; sourceTree = ""; }; + 5BC29BCB1F62B992007D2AA7 /* PincodeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PincodeViewModel.swift; sourceTree = ""; }; B25D051A1EA2CCBD0039D323 /* ReactiveSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveSwift.framework; path = Carthage/Build/iOS/ReactiveSwift.framework; sourceTree = ""; }; C72D32C91C470F5200F88B11 /* RACNest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RACNest.app; sourceTree = BUILT_PRODUCTS_DIR; }; C72D32DD1C470F5300F88B11 /* RACNestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RACNestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -94,6 +98,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5BC29BC71F62B92D007D2AA7 /* Pincode */ = { + isa = PBXGroup; + children = ( + 5BC29BC81F62B970007D2AA7 /* PincodeViewController.swift */, + 5BC29BCA1F62B975007D2AA7 /* Components */, + ); + path = Pincode; + sourceTree = ""; + }; + 5BC29BCA1F62B975007D2AA7 /* Components */ = { + isa = PBXGroup; + children = ( + 5BC29BCB1F62B992007D2AA7 /* PincodeViewModel.swift */, + ); + path = Components; + sourceTree = ""; + }; C72D32C01C470F5200F88B11 = { isa = PBXGroup; children = ( @@ -164,6 +185,7 @@ C72D32F61C470FE000F88B11 /* ViewControllers */ = { isa = PBXGroup; children = ( + 5BC29BC71F62B92D007D2AA7 /* Pincode */, C7852C3B1C4ACF9500375089 /* Form */, C76C20591C504B750083F4F5 /* Search */, C7852C3E1C4ACFE000375089 /* Main */, @@ -410,9 +432,11 @@ C7CB87121C51AB8E00ED9AE6 /* GenericTableCell.swift in Sources */, C7852C461C4ACFE000375089 /* MainCellItem.swift in Sources */, C7F740591C4B2A2F00519895 /* UserDefaults.swift in Sources */, + 5BC29BC91F62B970007D2AA7 /* PincodeViewController.swift in Sources */, C7852C331C4ACAAA00375089 /* StoryboardViewController.swift in Sources */, C72D330F1C471B3000F88B11 /* TableViewProtocols.swift in Sources */, C76C205B1C504B9D0083F4F5 /* SearchViewController.swift in Sources */, + 5BC29BCC1F62B992007D2AA7 /* PincodeViewModel.swift in Sources */, C72D330D1C471B0C00F88B11 /* CellProtocols.swift in Sources */, C7852C4C1C4B0E2B00375089 /* FormViewModel.swift in Sources */, C7852C351C4ACB6B00375089 /* Storyboard.swift in Sources */, diff --git a/RACNest/AppRelated/StoryboardViewController.swift b/RACNest/AppRelated/StoryboardViewController.swift index 6480097..11c6550 100644 --- a/RACNest/AppRelated/StoryboardViewController.swift +++ b/RACNest/AppRelated/StoryboardViewController.swift @@ -3,4 +3,5 @@ import UIKit enum StoryboardViewController : String, StoryboardViewControllerType { case Form = "FormViewController" case Search = "SearchViewController" + case Pincode = "PincodeViewController" } diff --git a/RACNest/Resources/Base.lproj/Main.storyboard b/RACNest/Resources/Base.lproj/Main.storyboard index c3dd791..922fb8f 100644 --- a/RACNest/Resources/Base.lproj/Main.storyboard +++ b/RACNest/Resources/Base.lproj/Main.storyboard @@ -1,8 +1,13 @@ - - + + + + + - + + + @@ -14,15 +19,15 @@ - + - - + + - + @@ -48,11 +53,11 @@ - + - + @@ -61,7 +66,7 @@ - + @@ -70,11 +75,11 @@ - + @@ -92,18 +97,18 @@ - + - + - + - + @@ -128,7 +133,7 @@ - + @@ -139,5 +144,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RACNest/ViewControllers/Main/Components/MainViewModel.swift b/RACNest/ViewControllers/Main/Components/MainViewModel.swift index 14c83f2..f4d2b65 100644 --- a/RACNest/ViewControllers/Main/Components/MainViewModel.swift +++ b/RACNest/ViewControllers/Main/Components/MainViewModel.swift @@ -8,8 +8,9 @@ final class MainViewModel: NSObject { let item1 = MainCellItem(title: "1. Form 🐥", identifier: .Form) let item2 = MainCellItem(title: "2. Search 🔍", identifier: .Search) + let item3 = MainCellItem(title: "3. Pincode 🔐", identifier: .Pincode) - items = [item1, item2] + items = [item1, item2, item3] super.init() } diff --git a/RACNest/ViewControllers/Pincode/Components/PincodeViewModel.swift b/RACNest/ViewControllers/Pincode/Components/PincodeViewModel.swift new file mode 100644 index 0000000..40a606e --- /dev/null +++ b/RACNest/ViewControllers/Pincode/Components/PincodeViewModel.swift @@ -0,0 +1,55 @@ +import ReactiveCocoa +import ReactiveSwift +import Result + +struct PincodeViewModel { + enum State { + case initial + case oldPincodeCorrect(message: String?) + case confirmationPending(pincode: String) + case newPincodeConfirmed(pincode: String) + case error(reason: String) + } + + let enterCodeAction: Action + let input: MutableProperty = MutableProperty("") + let state: Property + + init(currentPincode: String) { + let inputValid: (String) -> Bool = { + !$0.isEmpty && Int($0) != nil + } + enterCodeAction = Action(state: input, enabledIf: inputValid) { state, _ in + SignalProducer(value: state) + } + + // Reset the input after each action + input <~ enterCodeAction.values.map { _ in return "" } + + let state = enterCodeAction.values.scan(State.initial) { (currentState, pincode) in + let nextState: State + switch currentState { + case .initial: + // Initially, the pin code entered has to match the current pincode + nextState = (pincode == currentPincode) ? + .oldPincodeCorrect(message: nil) : + .error(reason: "Wrong pincode") + case .oldPincodeCorrect: + // If the old pincode was entered correct, the next input will be the new pincode + nextState = (pincode == currentPincode) ? + .oldPincodeCorrect(message: "You cant use your current pincode again, please enter a new pincode") : + .confirmationPending(pincode: pincode) + case .confirmationPending(let newPincode): + // The confirmation has to match the new pincode + nextState = (newPincode == pincode) ? + .newPincodeConfirmed(pincode: newPincode) : + .error(reason: "Confirmation does not match") + default: + // After the new pincode was confirmed or an error, nothing changes anymore + nextState = currentState + } + return nextState + } + self.state = Property(initial: .initial, then: state) + } +} diff --git a/RACNest/ViewControllers/Pincode/PincodeViewController.swift b/RACNest/ViewControllers/Pincode/PincodeViewController.swift new file mode 100644 index 0000000..c66f9e3 --- /dev/null +++ b/RACNest/ViewControllers/Pincode/PincodeViewController.swift @@ -0,0 +1,72 @@ +import UIKit +import ReactiveSwift +import ReactiveCocoa + +class PincodeViewController: UIViewController { + + @IBOutlet weak var pincodeField: UITextField! + @IBOutlet weak var enterButton: UIButton! + @IBOutlet weak var helpLabel: UILabel! + + private var viewModel: PincodeViewModel = PincodeViewModel(currentPincode: "0000") + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.input <~ pincodeField.reactive.continuousTextValues.filterMap { $0 } + pincodeField.reactive.text <~ viewModel.input + enterButton.reactive.pressed = CocoaAction(viewModel.enterCodeAction) + + helpLabel.reactive.text <~ viewModel.state.producer.map { + switch $0 { + case .initial: + return "Enter your current pincode (Hint: its 0000)" + case .oldPincodeCorrect(let message): + return message ?? "Enter your new pincode" + case .confirmationPending(_): + return "Confirm your new pincode" + case .newPincodeConfirmed(_): + return "Done!" + case .error(let reason): + return reason + } + } + + helpLabel.reactive.textColor <~ viewModel.state.producer.map { + switch $0 { + case .error(_): + return .red + default: + return .darkGray + } + } + + enterButton.reactive.title <~ viewModel.state.producer.map { + switch $0 { + case .initial, .oldPincodeCorrect: + return "Enter" + case .confirmationPending(_): + return "Confirm" + case .newPincodeConfirmed(_): + return "Done" + case .error(_): + return "Done" + } + } + + viewModel.state.signal.take { (state) -> Bool in + switch state { + case .newPincodeConfirmed(_), .error(_): + return false + default: + return true + } + }.observeCompleted { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.navigationController?.popViewController(animated: true) + } + } + + pincodeField.becomeFirstResponder() + } +} diff --git a/README.md b/README.md index e0cc76c..089bdb4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Learning FRP takes time, learning how to apply the FRP paradigm to your app take Examples -------- -1. [Form 🐥](Documents/1_form.md) +1. [Form 🐥](Documents/1_form.md) 2. [Composition 🚗🚕🚙](Documents/2_composition.md) -2. [Search 🔍](Documents/3_search.md) +3. [Search 🔍](Documents/3_search.md) +4. [Pincode 🔐](Documents/4_pincode.md) Contributing -----------