ReactableKit์ Combine ๊ธฐ๋ฐ์ผ๋ก ๋ง๋ค์ด์ง, SwiftUI ์ ํ๋ฆฌ์ผ์ด์ ์ ์ํ ๊ฐ๋ณ์ง๋ง ๊ฐ๋ ฅํ ์ํ ๊ด๋ฆฌ ํ๋ ์์ํฌ์ ๋๋ค. ์ด ํ๋ ์์ํฌ๋ ReactorKit ์ํคํ ์ฒ๋ฅผ ๋ฐํ์ผ๋ก ํ์ฌ, ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์ํ ๋ณํ์ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์๋ ๊ตฌ์กฐ์ ์ธ ๋ฐฉ์์ ์ ๊ณตํฉ๋๋ค.
ObservableEvent
(๋ถ๋ชจ ์์๊ฐ ํต์ )ReactableView
ํ๋กํ ์ฝDependencyInjectable
& Factory
ํจํด ์ฌ์ฉ๋ฒ
ReactableKit
์ Swift Package Manager๋ฅผ ํตํด ์ฝ๊ฒ ์ค์นํ ์ ์์ต๋๋ค. Xcode์์ ํ๋ก์ ํธ๋ฅผ ์ด๊ณ , ๋ฉ๋ด์์ File
> Add Packages...
๋ฅผ ์ ํํ ํ, ์๋ URL์ ์
๋ ฅํ์ธ์:
https://github.com/topwiz/ReactableKit.git
๋๋ Package.swift
ํ์ผ์ ์๋์ ๊ฐ์ด ์ง์ ์ถ๊ฐํ ์๋ ์์ต๋๋ค:
dependencies: [
.package(url: "https://github.com/topwiz/ReactableKit.git", from: `version`)
]
Reactable
์ ์ฌ์ฉํ๋ ค๋ฉด Reactable
ํ๋กํ ์ฝ์ ์ค์ํ๋ ํด๋์ค๋ฅผ ์์ฑํ์ธ์. Action
, Mutation
, State
์ ์ ์ํ๊ณ , mutate(action:)
์ reduce(state:mutation:)
๋ฅผ ๊ตฌํํฉ๋๋ค.
final class CounterReactable: Reactable {
enum Action {
case increase
case decrease
}
struct State {
var count: Int = 0
}
enum Mutation {
case setCount(Int)
}
var initialState = State()
func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
switch action {
case .increase:
return .just(.setCount(self.currentState.count + 1))
case .decrease:
return .run { send in
await send(.setCount(self.currentState.count - 1))
}
}
}
func reduce(state: inout State, mutate: Mutation) {
switch mutate {
case let .setCount(value):
state.count = value
}
}
}
transformAction
์ ํตํ ์ก์
๋ณํtransformAction
์ ์๋์ผ๋ก ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ก์
ํธ๋ฆฌ๊ฑฐ๋ฅผ ํ์ฑํํฉ๋๋ค. ์ด๋ ํ์ด๋จธ, ๊ฐ์ข
์ก์
์ Reactable Action์ผ๋ก ๋ณํํ๋๋ฐ ์ ์ฉํฉ๋๋ค.
final class CounterReactable: Reactable {
enum Action {
case increase
case autoIncrease
}
struct State {
var count: Int = 0
}
enum Mutation {
case setCount(Int)
}
var initialState = State()
func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
switch action {
case .increase:
return .just(.setCount(self.currentState.count + 1))
case .autoIncrease:
return .just(.setCount(self.currentState.count + 2))
}
}
func reduce(state: inout State, mutation: Mutation) {
switch mutation {
case let .setCount(value):
state.count = value
}
}
func transformAction() -> AnyPublisher<Action, Never> {
return .merge([
Timer.publish(every: 5, on: .main, in: .common)
.autoconnect()
.map { _ in Action.autoIncrease }
.eraseToAnyPublisher()
])
}
}
โ ๏ธ
Store
์ ์ฌ์ฉํ์ง ์๋๋ค๋ฉด Reactableinit
์ ์๋์ผ๋กinitialize()
์ ๋ถ๋ฌ์ค์ผ ํฉ๋๋ค.
Store
๋ฅผ ์ฌ์ฉํ์ฌ ์ํ ๋ณํ๋ฅผ ๊ฐ์งํ๊ณ SwiftUI ๋ทฐ ๋ด์์ Action
์ ๋์คํจ์นํ ์ ์์ต๋๋ค.
struct CounterView: View {
@StateObject var store = Store {
CounterReactable()
}
var body: some View {
VStack {
Text("\(self.store.state.count)")
.font(.largeTitle)
.padding()
Button("Increase") {
store.action(.increase)
}
}
}
}
updateOn
: SwiftUI ์
๋ฐ์ดํธ ์ต์ ํupdateOn
์ SwiftUI ์ํ ๊ด์ฐฐ์๋ก, ๋ทฐ๊ฐ ํ์ํ ๊ฒฝ์ฐ์๋ง ์
๋ฐ์ดํธ๋๋๋ก ๋ณด์ฅํฉ๋๋ค.
struct CounterView: View {
@ObservedObject var store = Store {
CounterReactable()
}
var body: some View {
VStack(spacing: 20) {
// โ
`count`๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง UI ์
๋ฐ์ดํธ. count1์ด ๋ณ๊ฒฝ๋ ๋๋ ์
๋ฐ์ดํธ๋์ง ์์.
self.store.updateOn(\.count) { value in
Text("\(value)")
.font(.headline)
}
// ํญ์ ์
๋ฐ์ดํธ๋จ
Text("\(self.store.state.count1)")
.font(.headline)
// Binding<Value> ์์
self.store.updateOn(\.isOn1) { value in
Toggle(isOn: value) {
Text("Toggle 1")
}
}
// Action์ด ํฌํจ๋ Binding<Value> ์์
self.store.updateOn(\.isOn1) { value in
Toggle(isOn: value) {
Text("Toggle 1 updateOn with action")
}
} action: { newValue in
.isOnChanged
}
// ForEach List ์์
ForEach(self.store.state.list) { item in
self.store.updateOn(\.list, for: item.id) { value in
Text("\(value.index)")
.font(.headline)
}
}
// ForEach List ๋ค์ค ๋ทฐ ์์
ForEach(self.store.state.list) { item in
HStack {
self.store.updateOn(\.list, for: item.id, property: \.index) { value in
Text("\(value)")
.font(.headline)
}
self.store.updateOn(\.list, for: item.id, property: \.toggle) { value in
Toggle(isOn: value) {
Text("Toggle 2 updateOn")
}
}
}
}
}
}
}
@ViewState
@ViewState
๋ ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋ ์๋ UI ์
๋ฐ์ดํธ๋ฅผ ๋ณด์ฅํฉ๋๋ค. @ViewState
๊ฐ ์๋ ํ๋กํผํฐ๋ SwiftUI ์
๋ฐ์ดํธ๋ฅผ ํธ๋ฆฌ๊ฑฐํ์ง ์์ต๋๋ค.
struct State {
@ViewState var count: Int = 1
/// ignoreEquality = true ์ธ ๊ฒฝ์ฐ๋ ๊ฐ์ ๊ฐ์ด set์ด ๋๋ฉด SwiftUI View๊ฐ ์
๋ฐ์ดํธ ๋ฉ๋๋ค.
@ViewState(ignoreEquality: true) var forceUpdate: Bool = false
/// animation: ์ ๋๋ฉ์ด์
์ ์ค์ ํ ๊ฒฝ์ฐ๋ ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋ ์ ๋๋ฉ์ด์
์ ์ ์ฉํฉ๋๋ค.
@ViewState(animation: .default) var forceUpdate: Bool = false
}
@Shared
@Shared
๋ ๋ถ๋ชจ์ ์์ ์ปดํฌ๋ํธ ๊ฐ์ ์ํ ๊ณต์ ๋ฅผ ๊ฐ๋ฅํ๊ฒ ํฉ๋๋ค.
/// `file`, `UserDefailt` ์ ์ฅ์๋ Codable๋ฅผ ์ค์ ํด์ผํฉ๋๋ค.
struct SharedState: Codable, Equatable {
var username: String = ""
var age: Int = 0
var isPremium: Bool = false
}
struct State {
@Shared(.file()) var sharedState = SharedState()
@Shared(.file(path: "Test/")) var sharedState = SharedState() // ์๋ธ ํด๋ ๊ฒฝ๋ก
@Shared var sharedState = SharedState()
@Shared(key: "custom_key") var sharedState = SharedState() // ์ปค์คํ
ํค
@ViewState var displayInfo: String = ""
}
โ ๏ธ
@Shared
๋ ๊ฐ์ด ๋ณ๊ฒฝ๋์ด๋ UI๋ฅผ ์๋์ผ๋ก ์ ๋ฐ์ดํธํ์ง ์์ต๋๋ค.
@Emit
์ฌ์ฉ@Emit
์ ๊ฐ์ด ๋์ผํ๊ฒ ์ค์ ๋๋๋ผ๋ ์
๋ฐ์ดํธ๋ฅผ ํธ๋ฆฌ๊ฑฐํฉ๋๋ค.
@Emit
์ฌ์ฉํ๊ธฐstruct MyState {
@Emit var title: String = "Hello"
}
emit(_:)
๊ตฌ๋
ํ๊ธฐreactable.emit(\.$title)
.sink { newValue in
print("Title updated:", newValue)
}
.store(in: &cancellables)
@Emit
์ฌ์ฉํ๊ธฐZStack { }
.emit(\.$title, from: self.store) { value in
print("Title updated:", value)
}
ObservableEvent
(๋ถ๋ชจ ์์๊ฐ ํต์ )ObservableEvent
๋ ์์ ์ปดํฌ๋ํธ์ ๋ถ๋ชจ ์ปดํฌ๋ํธ๊ฐ ์ก์
์ ์ ์กํ ์ ์๊ฒ ํด์ค๋๋ค.
โ ๏ธ Action์ด ๋๋์์ ์ ์๋ ์ State๋ฅผ ๋ถ๋ชจ์๊ฒ ์ ๋ฌํ์ง๋ง, ํ์ด๋ฐ ์ด์๋ก ์ต์ state๊ฐ ์๋์ ์์ต๋๋ค. ์ด๋ค ์๋ Reactable์ด ๋ณด๋๋์ง ํ๋จํ๋ ์ฉ๋๋ก ์ฌ์ฉํด์ฃผ์ธ์.
// ์์ Reactable
class ChildReactable: Reactable, ObservableEvent {
enum Action {
case notifyParent(Int)
}
}
// ๋ถ๋ชจ Reactable
func transformAction() -> AnyPublisher<Action, Never> {
// ๊ธ๋ก๋ฒํ๊ฒ ๋ชจ๋ ChildReactable์ ์ก์
๊ณผ ๋ณ๊ฒฝ๋ ์ํ๋ฅผ ๊ด์ฐฐ
let childEvent = ChildReactable.observe()
.filter { result in // result ์๋ ๋ฐ์ํ ์ก์
๊ณผ ์ก์
์ด ๋๋ ์์ ์ Child State๊ฐ ํฌํจ๋ฉ๋๋ค.
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
// ํน์ reactable ์ก์
์ ๊ด์ฐฐ
let localChildEvent = self.currentState.childReactable.observe()
.filter { result in // result ์๋ ๋ฐ์ํ ์ก์
๊ณผ ์ก์
์ด ๋๋ ์์ ์ Child State๊ฐ ํฌํจ๋ฉ๋๋ค.
if case .notifyParent = result.action { return true }
return false
}
.map(Action.parentAction)
.eraseToAnyPublisher()
return .merge([
childEvent,
])
}
ReactableView
ํ๋กํ ์ฝUIKit ๋ทฐ์์ @MainActor๋ฅผ ๋ฐ๋ฅด๋ ReactableView
ํ๋กํ ์ฝ์ ์ฌ์ฉํฉ๋๋ค.
final class UIKitView: UIView {
var cancellables: Set<AnyCancellable> = []
override init(frame: CGRect) {
super.init(frame: frame)
self.reactable = .init()
}
}
extension UIKitView: ReactableView {
// self.reactable์ด ์ค์ ๋๋ฉด ํธ์ถ๋ฉ๋๋ค.
func bind(reactable: UIKitReactable) {
}
}
DependencyInjectable
& Factory
ํจํด ์ฌ์ฉ๋ฒ์์กด์ฑ ์ฃผ์ ์์คํ ๊ณผ ํฉํ ๋ฆฌ ํจํด์ ๊ฒฐํฉํ์ฌ, real, preview, test ํ๊ฒฝ์์ ๊ฐ์ฒด ์์ฑ ๋ฐ ์์กด์ฑ ๊ด๋ฆฌ๋ฅผ ๊ฐ์ํํฉ๋๋ค.
DependencyInjectable
ํ๋กํ ์ฝ์ ํ์
์ด ์๋ก ๋ค๋ฅธ ํ๊ฒฝ์ ๋ํ ์์กด์ฑ์ ์ ์ํ ์ ์๋๋ก ํฉ๋๋ค.
public protocol DependencyInjectable {
associatedtype DependencyType
static var real: DependencyType { get }
static var preview: DependencyType { get } // optional
static var test: DependencyType { get } // optional
}
protocol ServiceProtocol {
func test() -> String
}
struct Service: ServiceProtocol {
func test() -> String { "real" }
struct Mock: ServiceProtocol {
public init() {}
public func test() -> String { "mock" }
}
struct TestMock: ServiceProtocol {
public init() {}
public func test() -> String { "test" }
}
}
extension Service: DependencyInjectable {
static var real: ServiceProtocol { Service() }
static var preview: ServiceProtocol { Service.Mock() }
static var test: ServiceProtocol { Service.TestMock() }
}
extension GlobalDependencyKey {
var service: ServiceProtocol {
self[Service.self]
}
}
// usage
@Dependency(\.service) var service
@MainActor๊ฐ ํ์ํ Factory๋ ViewFactory
๋ฅผ ์ฌ์ฉํฉ๋๋ค.
final class TestObject: Factory {
struct Payload {
var text: String
}
let payload: Payload
init(payload: Payload) {
self.payload = payload
}
func print1() {
print(self.payload.text)
}
}
extension TestObject: DependencyInjectable {
typealias DependencyType = TestObject.Factory
static var real: TestObject.Factory { .init() }
}
extension GlobalDependencyKey {
var testObjectFactory: TestObject.Factory {
self[TestObject.self]
}
}
// usage
@Dependency(\.testObjectFactory) var testObjectFactory
AnyFactory
๋ ๊ฐ์ฒด ์์ฑ ๊ณผ์ ์ ์ถ์ํํ๋ ์ ๋ค๋ฆญ ๋ํผ์
๋๋ค.
Factory๋ฅผ ์ด์ฉํ์ฌ ๊ฐ์ฒด๋ฅผ ์์ฑํ ํ, ๋ณํ ํด๋ก์ ๋ฅผ ํตํด ์ํ๋ ์ถ๋ ฅ ํ์
์ผ๋ก ๋ณํํฉ๋๋ค.
// Define the protocol for testing
protocol FactoryTestProtocol {
func test() -> String
}
// Real factory implementation
struct FactoryTest: FactoryTestProtocol, Factory {
struct Payload { }
let payload: Payload
init(payload: Payload) {
self.payload = payload
}
func test() -> String { "real \(payload)" }
}
// Mock factory implementation
struct FactoryTestMock: FactoryTestProtocol, Factory {
let payload: FactoryTest.Payload
init(payload: FactoryTest.Payload) {
self.payload = payload
}
func test() -> String { "mock \(payload)" }
}
// Conform FactoryTest to DependencyInjectable using AnyFactory
extension FactoryTest: DependencyInjectable {
typealias DependencyType = AnyFactory<FactoryTestProtocol, Payload>
static var real: DependencyType {
DependencyType(factory: FactoryTest.Factory())
}
static var test: DependencyType {
DependencyType(factory: FactoryTestMock.Factory())
}
}
extension GlobalDependencyKey {
var factoryTestFactory: FactoryTest.DependencyType {
self[FactoryTest.self]
}
}
ReactableKit is available under the MIT license.