diff --git a/apps/TesterIntegrated/swift/App.swift b/apps/TesterIntegrated/swift/App.swift index c70decb9..23cb3127 100644 --- a/apps/TesterIntegrated/swift/App.swift +++ b/apps/TesterIntegrated/swift/App.swift @@ -1,6 +1,7 @@ import Brownie import ReactBrownfield import SwiftUI +import UIKit let initialState = BrownfieldStore( counter: 0, @@ -83,6 +84,11 @@ struct MyApp: App { ReactNativeView(moduleName: "ReactNative") .navigationBarHidden(true) } + + NavigationLink("Push UIKit Screen") { + UIKitExampleViewControllerRepresentable() + .navigationBarTitleDisplayMode(.inline) + } } }.navigationViewStyle(StackNavigationViewStyle()) } @@ -118,3 +124,11 @@ struct MyApp: App { } } } + +struct UIKitExampleViewControllerRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIKitExampleViewController { + UIKitExampleViewController() + } + + func updateUIViewController(_ uiViewController: UIKitExampleViewController, context: Context) {} +} diff --git a/apps/TesterIntegrated/swift/SwiftExample.xcodeproj/project.pbxproj b/apps/TesterIntegrated/swift/SwiftExample.xcodeproj/project.pbxproj index 7b34bf44..47e65af2 100644 --- a/apps/TesterIntegrated/swift/SwiftExample.xcodeproj/project.pbxproj +++ b/apps/TesterIntegrated/swift/SwiftExample.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 2869185C23129ECF00458242 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2869185B23129ECF00458242 /* App.swift */; }; 2869186323129ED100458242 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2869186223129ED100458242 /* Assets.xcassets */; }; 2869186623129ED100458242 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2869186423129ED100458242 /* LaunchScreen.storyboard */; }; + 76208ADC2F11557800737E1D /* UIKitExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76208ADB2F11557800737E1D /* UIKitExampleViewController.swift */; }; AC7E040409DFEE553311B27E /* libPods-SwiftExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CF9653D1882655DA02862F71 /* libPods-SwiftExample.a */; }; BFSTORE001000000000000001 /* BrownfieldStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFSTORE001000000000000002 /* BrownfieldStore.swift */; }; ED52AE6E1313AAE3C968412D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 33BE347ABB4EE17F7F99D32D /* PrivacyInfo.xcprivacy */; }; @@ -23,6 +24,7 @@ 2869186723129ED100458242 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 33BE347ABB4EE17F7F99D32D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 5BE9069F7C8AF5853F650B68 /* Pods-SwiftExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftExample.debug.xcconfig"; path = "Target Support Files/Pods-SwiftExample/Pods-SwiftExample.debug.xcconfig"; sourceTree = ""; }; + 76208ADB2F11557800737E1D /* UIKitExampleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitExampleViewController.swift; sourceTree = ""; }; BFSTORE001000000000000002 /* BrownfieldStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrownfieldStore.swift; sourceTree = ""; }; CF9653D1882655DA02862F71 /* libPods-SwiftExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SwiftExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E74DC3AFAC0967529ED1C063 /* Pods-SwiftExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftExample.release.xcconfig"; path = "Target Support Files/Pods-SwiftExample/Pods-SwiftExample.release.xcconfig"; sourceTree = ""; }; @@ -43,6 +45,7 @@ 2869184F23129ECF00458242 = { isa = PBXGroup; children = ( + 76208ADB2F11557800737E1D /* UIKitExampleViewController.swift */, 2869185B23129ECF00458242 /* App.swift */, BFSTORE001000000000000003 /* Generated */, 2869186223129ED100458242 /* Assets.xcassets */, @@ -168,10 +171,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-frameworks.sh\"\n"; @@ -243,10 +250,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SwiftExample/Pods-SwiftExample-resources.sh\"\n"; @@ -259,6 +270,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 76208ADC2F11557800737E1D /* UIKitExampleViewController.swift in Sources */, 2869185C23129ECF00458242 /* App.swift in Sources */, BFSTORE001000000000000001 /* BrownfieldStore.swift in Sources */, ); @@ -340,10 +352,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -407,10 +416,7 @@ MTL_FAST_MATH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/apps/TesterIntegrated/swift/UIKitExampleViewController.swift b/apps/TesterIntegrated/swift/UIKitExampleViewController.swift new file mode 100644 index 00000000..25713549 --- /dev/null +++ b/apps/TesterIntegrated/swift/UIKitExampleViewController.swift @@ -0,0 +1,116 @@ +import UIKit +import Brownie + +class UIKitExampleViewController: UIViewController { + private var store: Store? + private var cancelSubscription: (() -> Void)? + + private let counterLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 24, weight: .bold) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let userLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 18) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let nameTextField: UITextField = { + let field = UITextField() + field.borderStyle = .roundedRect + field.placeholder = "Enter name" + field.translatesAutoresizingMaskIntoConstraints = false + return field + }() + + private let incrementButton: UIButton = { + var config = UIButton.Configuration.borderedProminent() + config.title = "Increment" + let button = UIButton(configuration: config) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupStore() + } + + private func setupUI() { + title = "UIKit Example" + view.backgroundColor = .systemBackground + + let titleLabel = UILabel() + titleLabel.text = "UIKit + Brownie Store" + titleLabel.font = .systemFont(ofSize: 20, weight: .bold) + titleLabel.textAlignment = .center + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let stack = UIStackView(arrangedSubviews: [ + titleLabel, + counterLabel, + userLabel, + nameTextField, + incrementButton + ]) + stack.axis = .vertical + stack.spacing = 16 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20) + ]) + + incrementButton.addTarget(self, action: #selector(incrementTapped), for: .touchUpInside) + nameTextField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) + } + + private func setupStore() { + store = StoreManager.get(key: BrownfieldStore.storeName, as: BrownfieldStore.self) + + guard let store else { + counterLabel.text = "Store not found" + return + } + + updateUI(with: store.state) + + cancelSubscription = store.subscribe { [weak self] state in + self?.updateUI(with: state) + } + } + + private func updateUI(with state: BrownfieldStore) { + counterLabel.text = "Count: \(Int(state.counter))" + userLabel.text = "User: \(state.user.name)" + + if nameTextField.text != state.user.name && !nameTextField.isFirstResponder { + nameTextField.text = state.user.name + } + } + + @objc private func incrementTapped() { + store?.set { $0.counter += 1 } + } + + @objc private func nameChanged() { + guard let name = nameTextField.text else { return } + store?.set { $0.user.name = name } + } + + deinit { + cancelSubscription?() + } +} diff --git a/packages/brownie/ios/BrownieStore.swift b/packages/brownie/ios/BrownieStore.swift index 7f075cd5..4a440bf5 100644 --- a/packages/brownie/ios/BrownieStore.swift +++ b/packages/brownie/ios/BrownieStore.swift @@ -148,4 +148,28 @@ public class Store: ObservableObject { public subscript(_ keyPath: KeyPath) -> Value { state[keyPath: keyPath] } + + // MARK: - UIKit Support + + /// Subscribe to state changes with a closure. Returns a cancellation function. + public func subscribe(onChange: @escaping (State) -> Void) -> () -> Void { + let cancellable = $state.sink { state in + onChange(state) + } + return { cancellable.cancel() } + } + + /// Subscribe to specific property changes. Returns a cancellation function. + public func subscribe( + _ keyPath: KeyPath, + onChange: @escaping (Value) -> Void + ) -> () -> Void { + let cancellable = $state + .map { $0[keyPath: keyPath] } + .removeDuplicates() + .sink { value in + onChange(value) + } + return { cancellable.cancel() } + } }