Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/royalicing/shohin
Pragmatic React/Elm-like components & state management for iOS
https://github.com/royalicing/shohin
cocoa-touch elm-architecture functional-programming swift uikit
Last synced: about 3 hours ago
JSON representation
Pragmatic React/Elm-like components & state management for iOS
- Host: GitHub
- URL: https://github.com/royalicing/shohin
- Owner: RoyalIcing
- Created: 2018-05-14T13:01:41.000Z (over 6 years ago)
- Default Branch: master
- Last Pushed: 2021-10-26T04:47:35.000Z (about 3 years ago)
- Last Synced: 2024-10-28T12:55:39.030Z (21 days ago)
- Topics: cocoa-touch, elm-architecture, functional-programming, swift, uikit
- Language: Swift
- Homepage:
- Size: 103 KB
- Stars: 5
- Watchers: 3
- Forks: 0
- Open Issues: 11
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# Shohin [![Build Status](https://travis-ci.org/RoyalIcing/Shohin.svg?branch=master)](https://travis-ci.org/RoyalIcing/Shohin)
Pragmatic React/Elm-like components & state management for iOS.
## Philosophy
- Completely opt-in: mix and match with normal iOS code.
- Pragmatic: integrates with instead of trying to replace UIKit. Keep using view controllers.
- Extensible: create your own view elements or commands.## Usage
First, we import the Shohin library. We are going to build a number counter, with buttons to increment and decrement. There will also a random button to choose a random number, and a text field to let the user choose their own number.
Without any of the interface, what our data model boils down to is a single number, which we can store as an `Int`.
We declare our data model `CounterModel`. Here we use a `struct` with an `Int` variable that will store our counter.
Our message `CounterMsg` has four possible choices for the different user interactions that will change the model. One to increment by 1, on to decrement by 1, one to completely change the counter to a specified `Int`, and another to change it to a random number.
~~~swift
import Shohinstruct CounterModel {
var counter: Int = 0
}enum CounterMsg {
case increment()
case decrement()
case setCounter(to: Int)
case randomize()
}
~~~Let's connect the model to the message with an update function, which takes a message and makes changes to the model.
~~~swift
let randomInt = RandomGenerator(toMessage: CounterMsg.setCounter)func update(message: CounterMsg, model: inout CounterModel) -> Command {
switch message {
case .increment():
model.counter += 1
case .decrement():
model.counter -= 1
case let .setCounter(newValue):
model.counter = newValue
case .randomize():
// Returns command to generate a random number
return randomInt.generate(min: 0, max: 10)
}
return [] // No command
}
~~~For `.randomize()`, we use a random generator here named `randomInt`. This is set up to send `CounterMsg.setCounter(to:)` with the generated random number passed in. It’s similar to a callback.
Let’s make a UI so people can view the model, and make changes to update it. Here we are making labels, fields, and buttons.
We identify each element that the user interacts with using the `CounterKey` string enum.
~~~swift
enum CounterKey: String {
case counter, increment, decrement, randomize, counterField
}func render(model: CounterModel) -> [Element] {
return [
label(CounterKey.counter, [
.text("Counter:"),
.textAlignment(.center),
]),
field(CounterKey.counterField, [
.text("\(model.counter)"),
.onChange { CounterMsg.setCounter(to: $0.text.flatMap(Int.init) ?? 0) }
]),
button(CounterKey.increment, [
.title("Increment", for: .normal),
.onPress(CounterMsg.increment),
]),
button(CounterKey.decrement, [
.title("Decrement", for: .normal),
.onPress(CounterMsg.decrement),
.set(\.tintColor, to: UIColor.red),
]),
button(CounterKey.randomize, [
.title("Randomize", for: .normal),
.onPress(CounterMsg.randomize),
]),
]
}
~~~We can use AutoLayout too, making constraints between each UI element, and to the superview’s margins guide.
~~~swift
func layout(model: CounterModel, context: LayoutContext) -> [NSLayoutConstraint] {
let margins = context.marginsGuide
let counterView = context.view(CounterKey.counter)!
let decrementButton = context.view(CounterKey.decrement)!
let incrementButton = context.view(CounterKey.increment)!
let randomizeButton = context.view(CounterKey.randomize)!
return [
counterView.centerXAnchor.constraint(equalTo: margins.centerXAnchor),
counterView.topAnchor.constraint(equalTo: margins.topAnchor),
decrementButton.leadingAnchor.constraint(equalTo: margins.leadingAnchor),
decrementButton.bottomAnchor.constraint(equalTo: margins.bottomAnchor),
incrementButton.trailingAnchor.constraint(equalTo: margins.trailingAnchor),
incrementButton.bottomAnchor.constraint(equalTo: margins.bottomAnchor),
randomizeButton.centerXAnchor.constraint(equalTo: margins.centerXAnchor),
randomizeButton.bottomAnchor.constraint(equalTo: margins.bottomAnchor),
]
}
~~~Now let's get everything connected and running.
~~~swift
let mainView = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
mainView.backgroundColor = #colorLiteral(red: 0.239215686917305, green: 0.674509823322296, blue: 0.968627452850342, alpha: 1.0)
// In a UIViewController, you would write the below in `viewDidLoad()`.
let program = Program(view: mainView, model: CounterModel(), initialCommand: [], update: update, render: render, layout: layout)
~~~We now have an interactive app! In summary:
1. You have a **model**, which is presented (**rendered** and **laid out**) to the user as views.
2. Interactions that the user makes (UI events) produce **messages**, which **update** the model.
3. These **updates** cause the views to be **rendered** and **laid out** again.## Glossary
- Model: the stored representation of your app's state. Usually a struct.
- Message: requests to change the model. Usually an enum.
- View element: value which represents a view (UIView / UIButton / UILabel etc) and its properties (‘props’)
- render: function implemented to take the current model and return view elements to be displayed to the user.
- layout: function implemented to take the current model and a context to the rendered views, and return an array of `NSLayoutConstraint` constraining the rendered view to each other and their containing superview.
- update: function implemented to take the current model and a message to change the model. You mutate the model, and after your render and layout functions will be called again to update the views.
- Program: connects all of the above into a running cycle of rendering, layout, respond to user input, and updating the model.## UIView Docs
### Program
```swift
class Program {
init(
view: UIView,
model: Model,
initialCommand: Command = default,
update: @escaping (Msg, inout Model) -> Command = default,
render: @escaping (Model) -> [ViewElement] = default,
layoutGuideForKey: @escaping (String) -> UILayoutGuide? = default,
layout: @escaping (Model, LayoutContext) -> [NSLayoutConstraint] = default
)var model: Model { get }
func send(_ message: Msg)
}
```### View Element
```swift
struct ViewElement {
typealias MakeView = (UIView?) -> UIView
typealias ViewAndRegisterEventHandler = (UIView, (String, MessageMaker, EventHandlingOptions) -> (Any?, Selector)) -> ()var key: String { get set }
var makeViewIfNeeded: MakeView { get set }
var applyToView: ViewAndRegisterEventHandler { get set }init(
key: String,
makeViewIfNeeded: @escaping MakeView,
applyToView: @escaping ViewAndRegisterEventHandler
)
}
```### Props
```swift
protocol ViewProp {
associatedtype View : UIViewstatic func set(_ keyPath: ReferenceWritableKeyPath, to value: Value) -> Self
}extension ViewProp {
static func tag(_ tag: Int) -> Self
}
```#### UILabel
```swift
enum LabelProp : ViewProp {
typealias View = UILabelcase text(String)
case textAlignment(NSTextAlignment)
case applyChange(ChangeApplier)
}func label(_ key: Key, _ props: [LabelProp]) -> ViewElement
```#### UITextField
```swift
enum FieldProp : ViewProp {
typealias View = UITextFieldcase text(String)
case textAlignment(NSTextAlignment)
case placeholder(String?)
case keyboardType(UIKeyboardType)
case returnKeyType(UIReturnKeyType)
case applyChange(ChangeApplier)
case on(UIControlEvents, toMessage: ((UITextField, UIEvent) -> Msg)?)
}func field(_ key: Key, _ props: [FieldProp]) -> ViewElement
```#### UIControl
```swift
enum ControlProp : ViewProp {
typealias View = Controlcase on(UIControlEvents, toMessage: (Control, UIEvent) -> Msg)
case applyChange(ChangeApplier, stage: Int)static func set(_ keyPath: ReferenceWritableKeyPath, to value: Value, stage: Int) -> ControlProp
}func control(_ key: Key, _ props: [ControlProp]) -> ViewElement
```#### UIButton
```swift
extension ControlProp where Control : UIButton {
static func title(_ title: String, for controlState: UIControlState) -> ControlProp
static func onPress(_ makeMessage: @escaping () -> Msg) -> ControlProp
}func button(_ key: Key, _ props: [ControlProp]) -> ViewElement
```#### UISlider
```swift
extension ControlProp where Control : UISlider {
static func value(_ value: Float) -> ControlProp
static func minimumValue(_ value: Float) -> ControlProp
static func maximumValue(_ value: Float) -> ControlProp
static var isContinuous: ControlProp
}func slider(_ key: Key, _ props: [ControlProp]) -> ViewElement
```#### UIStepper
```swift
extension ControlProp where Control : UIStepper {
static func value(_ value: Double) -> ControlProp
static func minimumValue(_ value: Double) -> ControlProp
static func maximumValue(_ value: Double) -> ControlProp
static var isContinuous: ControlProp
}func stepper(_ key: Key, _ props: [ControlProp]) -> ViewElement
```#### UISegmentedControl
```swift
struct Segment {
enum Content {
case title(String)
case image(UIImage)
}var key: String { get set }
var content: Content { get set }
var enabled = true { get set }
var width: CGFloat = 0 { get set }
}func segment(_ key: Key, _ content: Segment.Content, enabled: Bool = true, width: CGFloat = 0.0) -> Segment
extension UISegmentedControl {
var selectedSegmentKey: String! { get }
}enum SegmentedControlProp : ViewProp {
typealias View = UISegmentedControlcase selectedKey(String)
case segments([Segment])
case applyChange(ChangeApplier)
case on(UIControlEvents, toMessage: ((UISegmentedControl, UIEvent) -> Msg)?)
}func segmentedControl(_ key: Key, _ props: [SegmentedControlProp]) -> ViewElement
```#### For custom views
Add your own `static func` to an extension: `extension CustomViewProp where CustomView : YourViewClass {}`
```swift
enum CustomViewProp : ViewProp {
typealias View = CustomViewcase backgroundColor(CGColor?)
case applyChange(ChangeApplier)
}func customView(_ key: Key, _ viewClass: CustomView.Type, _ props: [CustomViewProp]) -> ViewElement
```### Event handling
```swift
class MessageMaker {
init(event makeMessage: @escaping (UIEvent) -> Msg)
init(control makeMessage: @escaping (Control, UIEvent) -> Msg)
init()
}struct EventHandlingOptions {
var resignFirstResponder: Bool { get set }init(
resignFirstResponder: Bool = false
)
}
```### Layout
```swift
class LayoutContext {
var marginsGuide: UILayoutGuide { get }
var safeAreaGuide: UILayoutGuide { get }
var readableContentGuide: UILayoutGuide { get }
var view: UIView { get }func view(_ key: Key) -> UIView?
func guide(_ key: Key) -> UILayoutGuide?
}
```### UITableView (alpha: subject to change)
- Model: the data used to render the table
- CellModel: the data representing a cell
- Msg: changes sent by rendered elements in the table
- `class TableAssistant`: used to render cells in a `UITableView````swift
public class TableAssistant {
public var tableView: UITableView
public var model: Modelpublic init(tableView: UITableView, initial: Model, update: @escaping (Msg, inout Model) -> ())
public func registerCells(reuseIdentifier: ReuseIdentifier, render: @escaping (CellModel) -> [CellProp], layout: @escaping (_ cellModel: CellModel, _ context: LayoutContext) -> [NSLayoutConstraint])
public func cell(_ reuseIdentifier: ReuseIdentifier, _ cellModel: CellModel) -> UITableViewCell
}
```#### Using with a `UITableViewController` subclass
```swift
struct Model {
// …
}enum CellIdentifier : String {
case red
case orange
case bluestatic let allCases: [CellIdentifier] = [
.red,
.orange,
.blue
]
}enum MyMsg {
case copyText(String)
}private struct MyItem {
// …
}extension CellIdentifier {
enum ElementKey : String {
case label
case button
}func render(item: MyItem) -> [CellProp] {
switch self {
case .red:
return [
.backgroundColor(UIColor.red),
]
case .orange:
return [
.backgroundColor(UIColor.orange),
.content([
label(ElementKey.label, [
.text("Orange"),
.set(\.textColor, to: black),
]),
button(ElementKey.button, [
.title("Orange", for: .normal),
.set(\.font, to: UIFont.boldSystemFont(ofSize: UIFont.buttonFontSize)),
.onPress { .copyText("Orange") }
])
])
]
case .blue:
return [
.backgroundColor(UIColor.blue),
.content([
label(ElementKey.label, [
.text("Blue"),
.set(\.textColor, to: black),
]),
button(ElementKey.button, [
.title("Blue", for: .normal),
.set(\.font, to: UIFont.boldSystemFont(ofSize: UIFont.buttonFontSize)),
.onPress { .copyText("Blue") }
])
])
]
}
}
}func layout(item: MyItem, context: LayoutContext) -> [NSLayoutConstraint] {
return [ … ]
}func update(message: MyMsg, model: inout Model) {
switch message {
case let .copyText(text):
UIPasteboard.general.string = text
}
}class MyTableViewController: UITableViewController {
private var tableAssistant: TableAssistant!override func viewDidLoad() {
super.viewDidLoad()let tableView = self.tableView!
tableView.allowsSelection = false
// etcself.tableAssistant = TableAssistant(tableView: tableView, initial: Model(), update: update)
for cellIdentifier in CellIdentifier.allCasess {
tableAssistant.registerCells(reuseIdentifier: cellIdentifier, render: cellIdentifier.render, layout: cellIdentifier.layout)
}
tableView.reloadData()
}// MARK: Table data source
override func numberOfSections(in tableView: UITableView) -> Int {
// …
}override func tableView(_ tableView: UITableView, numberOfRowsInSection sectionIndex: Int) -> Int {
// …
}override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = // …
let item = // …
return tableAssistant.cell(cellIdentifier, item)
}
}
```