diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ab4337580..36abc0653 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -11,23 +11,6 @@ permissions: env: MIX_ENV: test -steps: -- name: Update Homebrew - run: | - brew update --preinstall - cat "$(brew --repository)/Library/Taps/homebrew/homebrew-core/Formula/foo.rb" > .github/brew-formulae -- name: Configure Homebrew cache - uses: actions/cache@v2 - with: - path: | - ~/Library/Caches/Homebrew/foo--* - ~/Library/Caches/Homebrew/downloads/*--foo-* - key: brew-${{ hashFiles('.github/brew-formulae') }} - restore-keys: brew- -- name: Install Homebrew dependencies - run: | - env HOMEBREW_NO_AUTO_UPDATE=1 brew install xcodegen - jobs: build: name: Build and test diff --git a/.gitignore b/.gitignore index 61efa18f0..f70a33a71 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ live_view_native_swiftui-*.tar # Temporary files, for example, from tests. /tmp/ + +.vscode \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 0bd3fca8b..c6115dc39 100644 --- a/Package.resolved +++ b/Package.resolved @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/davidstump/SwiftPhoenixClient.git", "state" : { - "revision" : "613989bf2e562d8a851ea83741681c3439353b45", - "version" : "5.0.0" + "revision" : "588bf6baab5d049752748e19a4bff32421ea40ec", + "version" : "5.3.2" } }, { diff --git a/Package.swift b/Package.swift index 1f56ddc98..73b7b8695 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.3.2"), - .package(url: "https://github.com/davidstump/SwiftPhoenixClient.git", .upToNextMinor(from: "5.0.0")), + .package(url: "https://github.com/davidstump/SwiftPhoenixClient.git", .upToNextMinor(from: "5.3.2")), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/liveview-native/liveview-native-core-swift.git", exact: "0.2.1"), diff --git a/Sources/BuiltinRegistryGenerator/BuiltinRegistryGenerator.swift b/Sources/BuiltinRegistryGenerator/BuiltinRegistryGenerator.swift index de4f3c661..01fa70c12 100644 --- a/Sources/BuiltinRegistryGenerator/BuiltinRegistryGenerator.swift +++ b/Sources/BuiltinRegistryGenerator/BuiltinRegistryGenerator.swift @@ -132,21 +132,24 @@ struct BuiltinRegistryGenerator: ParsableCommand { // [platform] [version], ... let platform = Reference(Substring.self) let version = Reference(Double?.self) - let expression = Regex { - Capture(as: platform) { - OneOrMore(.word) + let platformExpr = Capture(as: platform) { + OneOrMore(.word) + } + let versionExpr = Capture(as: version) { + OneOrMore(.digit) + Optionally { + "." + OneOrMore(.digit) } + } transform: { + Double($0) + } + + let expression = Regex { + platformExpr Optionally { OneOrMore(.whitespace) - Capture(as: version) { - OneOrMore(.digit) - Optionally { - "." - OneOrMore(.digit) - } - } transform: { - Double($0) - } + versionExpr } } let availability = String(match[availability]) diff --git a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift index f3d32b5cc..8d3a6508c 100644 --- a/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift +++ b/Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift @@ -33,7 +33,7 @@ private let logger = Logger(subsystem: "LiveViewNative", category: "LiveSessionC @MainActor public class LiveSessionCoordinator: ObservableObject { /// The current state of the live view connection. - @Published public private(set) var state = LiveSessionState.notConnected + @Published public private(set) var state = LiveSessionState.setup /// The current URL this live view is connected to. public private(set) var url: URL @@ -103,11 +103,11 @@ public class LiveSessionCoordinator: ObservableObject { $navigationPath.scan(([LiveNavigationEntry](), [LiveNavigationEntry]()), { ($0.1, $1) }).sink { [weak self] prev, next in guard let self else { return } - let isDisconnected: Bool - if case .notConnected = next.last!.coordinator.state { - isDisconnected = true - } else { - isDisconnected = false + let isDisconnected = switch next.last!.coordinator.state { + case .setup, .disconnected: + true + default: + false } if next.last!.coordinator.url != next.last!.url || isDisconnected { Task { @@ -151,7 +151,7 @@ public class LiveSessionCoordinator: ObservableObject { /// /// You generally do not call this function yourself. It is called automatically when the ``LiveView`` appears. /// - /// This function is a no-op unless ``state`` is ``LiveSessionState/notConnected``. + /// This function is a no-op unless ``state`` is ``LiveSessionState/setup`` or ``LiveSessionState/disconnected`` or ``LiveSessionState/connectionFailed(_:)``. /// /// This is an async function which completes when the connection has been established or failed. /// @@ -159,7 +159,7 @@ public class LiveSessionCoordinator: ObservableObject { /// - Parameter httpBody: The HTTP body to send when requesting the dead render. public func connect(httpMethod: String? = nil, httpBody: Data? = nil) async { switch state { - case .notConnected, .connectionFailed: + case .setup, .disconnected, .connectionFailed: break default: return @@ -252,7 +252,7 @@ public class LiveSessionCoordinator: ObservableObject { } self.socket?.disconnect() self.socket = nil - self.state = .notConnected + self.state = .disconnected } /// Forces the session to disconnect then connect. @@ -418,7 +418,7 @@ public class LiveSessionCoordinator: ObservableObject { socket.off(refs) continuation.resume(returning: socket) }) - refs.append(socket.onError { [weak self, weak socket] (error) in + refs.append(socket.onError { [weak self, weak socket] (error, response) in guard let socket else { return } guard self != nil else { socket.disconnect() @@ -521,14 +521,16 @@ class LiveSessionURLSessionDelegate: NSObject, URLSessionTaskDe extension LiveSessionCoordinator { static var platform: String { "swiftui" } - static var platformParams: [String:String] { + static var platformParams: [String:Any] { [ "app_version": getAppVersion(), "app_build": getAppBuild(), "bundle_id": getBundleID(), "os": getOSName(), "os_version": getOSVersion(), - "target": getTarget() + "target": getTarget(), + "l10n": getLocalization(), + "i18n": getInternationalization() ] } @@ -607,6 +609,18 @@ extension LiveSessionCoordinator { } #endif } + + private static func getLocalization() -> [String:Any] { + [ + "locale": Locale.autoupdatingCurrent.identifier, + ] + } + + private static func getInternationalization() -> [String:Any] { + [ + "time_zone": TimeZone.autoupdatingCurrent.identifier, + ] + } } fileprivate extension URL { @@ -619,12 +633,24 @@ fileprivate extension URL { .init(name: "_format", value: LiveSessionCoordinator.platform) ]) } - for (key, value) in LiveSessionCoordinator.platformParams { - let name = "_interface[\(key)]" + /// Create a nested structure of query items. + /// + /// `_root[key][nested_key]=value` + func queryParameters(for object: [String:Any]) -> [(name: String, value: String?)] { + object.reduce(into: [(name: String, value: String?)]()) { (result, pair) in + if let value = pair.value as? String { + result.append((name: "[\(pair.key)]", value: value)) + } else if let nested = pair.value as? [String:Any] { + result.append(contentsOf: queryParameters(for: nested).map { + return (name: "[\(pair.key)]\($0.name)", value: $0.value) + }) + } + } + } + for queryItem in queryParameters(for: LiveSessionCoordinator.platformParams) { + let name = "_interface\(queryItem.name)" if !(components?.queryItems?.contains(where: { $0.name == name }) ?? false) { - result.append(queryItems: [ - .init(name: name, value: value) - ]) + result.append(queryItems: [.init(name: name, value: queryItem.value)]) } } return result diff --git a/Sources/LiveViewNative/Coordinators/LiveSessionState.swift b/Sources/LiveViewNative/Coordinators/LiveSessionState.swift index d3b2ade31..a399e4745 100644 --- a/Sources/LiveViewNative/Coordinators/LiveSessionState.swift +++ b/Sources/LiveViewNative/Coordinators/LiveSessionState.swift @@ -10,21 +10,23 @@ import Foundation /// The live view connection state. public enum LiveSessionState { /// The coordinator has not yet connected to the live view. - case notConnected + case setup /// The coordinator is attempting to connect. case connecting /// The coordinator is attempting to reconnect. case reconnecting /// The coordinator has connected and the view tree can be rendered. case connected - // todo: disconnected state? + /// The coordinator is disconnected. + case disconnected /// The coordinator failed to connect and produced the given error. case connectionFailed(Error) - /// Either `notConnected` or `connecting` + /// Either `setup` or `connecting` var isPending: Bool { switch self { - case .notConnected, + case .setup, + .disconnected, .connecting, .reconnecting: return true diff --git a/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift b/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift index f3f9b403f..5104ef190 100644 --- a/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift +++ b/Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift @@ -26,7 +26,7 @@ private let logger = Logger(subsystem: "LiveViewNative", category: "LiveViewCoor /// - ``handleEvent(_:handler:)`` @MainActor public class LiveViewCoordinator: ObservableObject { - @Published internal private(set) var internalState: LiveSessionState = .notConnected + @Published internal private(set) var internalState: LiveSessionState = .setup var state: LiveSessionState { internalState @@ -283,7 +283,7 @@ public class LiveViewCoordinator: ObservableObject { channel.on("phx_close") { [weak self, weak channel] message in Task { @MainActor in guard channel === self?.channel else { return } - self?.internalState = .notConnected + self?.internalState = .disconnected } } @@ -320,7 +320,7 @@ public class LiveViewCoordinator: ObservableObject { } await MainActor.run { [weak self] in self?.channel = nil - self?.internalState = .notConnected + self?.internalState = .disconnected } } diff --git a/Sources/LiveViewNative/Live/LiveView.swift b/Sources/LiveViewNative/Live/LiveView.swift index b57274b29..476e04a07 100644 --- a/Sources/LiveViewNative/Live/LiveView.swift +++ b/Sources/LiveViewNative/Live/LiveView.swift @@ -201,7 +201,9 @@ public struct LiveView< return .connecting case let .connectionFailed(error): return .error(error) - case .notConnected: + case .setup: + return .connecting + case .disconnected: return .disconnected case .reconnecting: return .reconnecting(_ConnectedContent(session: session)) diff --git a/Sources/LiveViewNative/NavStackEntryView.swift b/Sources/LiveViewNative/NavStackEntryView.swift index 5d1ab3585..27dbe5d88 100644 --- a/Sources/LiveViewNative/NavStackEntryView.swift +++ b/Sources/LiveViewNative/NavStackEntryView.swift @@ -30,12 +30,14 @@ struct NavStackEntryView: View { private var phase: LiveViewPhase { switch coordinator.state { - case .notConnected: - return .disconnected + case .setup: + return .connecting case .connecting: return .connecting case .connectionFailed(let error): return .error(error) + case .disconnected: + return .disconnected case .reconnecting, .connected: // these phases should always be handled internally fatalError() } diff --git a/Sources/LiveViewNative/Property Wrappers/ObservedElement.swift b/Sources/LiveViewNative/Property Wrappers/ObservedElement.swift index c4911567e..77c73f01e 100644 --- a/Sources/LiveViewNative/Property Wrappers/ObservedElement.swift +++ b/Sources/LiveViewNative/Property Wrappers/ObservedElement.swift @@ -85,8 +85,8 @@ public struct ObservedElement { } /// A publisher that publishes when the observed element changes. - public var projectedValue: some Publisher { - observer.objectWillChange + public var projectedValue: some Publisher { + observer.elementChangedPublisher } var children: [Node] { overrideElement.flatMap({ Array($0.children()) }) ?? observer.resolvedChildren } @@ -127,6 +127,8 @@ extension ObservedElement { var objectWillChange = ObjectWillChangePublisher() + var elementChangedPublisher: AnyPublisher! + init(_ id: NodeRef) { self.id = id } @@ -141,18 +143,21 @@ extension ObservedElement { self.resolvedChildren = Array(self.resolvedElement.children()) self._resolvedChildIDs = nil - let publisher: AnyPublisher<(), Never> + let id = self.id + if observeChildren { - publisher = Publishers.MergeMany( - [context.elementChanged(id)] + self.resolvedChildIDs.map(context.elementChanged) + self.elementChangedPublisher = Publishers.MergeMany( + [context.elementChanged(id).map({ id })] + self.resolvedChildIDs.map({ id in + context.elementChanged(id).map({ id }) + }) ) .eraseToAnyPublisher() self.observedChildIDs = self.resolvedChildIDs } else { - publisher = context.elementChanged(id).eraseToAnyPublisher() + self.elementChangedPublisher = context.elementChanged(id).map({ id }).eraseToAnyPublisher() } - cancellable = publisher + cancellable = self.elementChangedPublisher .sink { [weak self] _ in guard let self else { return } self.resolvedElement = context.document[id].asElement() diff --git a/Sources/LiveViewNative/Protocols/ContentBuilder.swift b/Sources/LiveViewNative/Protocols/ContentBuilder.swift index 887ca3d01..0eafd4bb7 100644 --- a/Sources/LiveViewNative/Protocols/ContentBuilder.swift +++ b/Sources/LiveViewNative/Protocols/ContentBuilder.swift @@ -340,6 +340,19 @@ public struct ContentBuilderContext: D let resolvedStylesheet: [String:[BuilderModifierContainer]] + public var coordinator: LiveViewCoordinator { + context.coordinator + } + + public var url: URL { + context.url + } + + @MainActor + public var document: Document? { + context.coordinator.document + } + func value(for _: OtherBuilder.Type = OtherBuilder.self) -> ContentBuilderContext.Value { return .init( coordinatorEnvironment: coordinatorEnvironment, @@ -363,9 +376,13 @@ public struct ContentBuilderContext: D static func resolveStylesheet( _ stylesheet: Stylesheet ) throws -> [String:[BuilderModifierContainer]] { - return try stylesheet.content.reduce(into: [:], { - $0.merge(try StylesheetParser>(context: .init()).parse($1.utf8), uniquingKeysWith: { $1 }) - }) + if Builder.ModifierType.self == EmptyContentModifier.self { + return [:] + } else { + return try stylesheet.content.reduce(into: [:], { + $0.merge(try StylesheetParser>(context: .init()).parse($1.utf8), uniquingKeysWith: { $1 }) + }) + } } } diff --git a/Sources/LiveViewNative/Stylesheets/AttributeReference.swift b/Sources/LiveViewNative/Stylesheets/AttributeReference.swift index 8ace6a234..70beeaeda 100644 --- a/Sources/LiveViewNative/Stylesheets/AttributeReference.swift +++ b/Sources/LiveViewNative/Stylesheets/AttributeReference.swift @@ -22,13 +22,21 @@ import LiveViewNativeCore /// /// The attribute will be automatically decoded to the correct type using the conformance to ``AttributeDecodable``. public struct AttributeReference: ParseableModifierValue { - enum Storage { + public enum Storage { case constant(Value) case reference(AttributeName) } let storage: Storage + public init(_ constant: Value) { + self.storage = .constant(constant) + } + + init(storage: Storage) { + self.storage = storage + } + public static func parser(in context: ParseableModifierContext) -> some Parser { OneOf { Value.parser(in: context).map(Storage.constant) @@ -49,6 +57,15 @@ public struct AttributeReference Value { + switch storage { + case .constant(let value): + return value + case .reference: + return `default` + } + } } extension AttributeName: ParseableModifierValue { diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/FocusScopeModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/FocusScopeModifier.swift index 3c58a515d..164f75d43 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/FocusScopeModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/FocusScopeModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// focusScope(attr("namespace")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MaskModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MaskModifier.swift index 6a529e119..08775e0fd 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MaskModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MaskModifier.swift @@ -20,16 +20,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// mask(alignment: .center, mask: :mask) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// /// /// ``` diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MatchedGeometryEffectModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MatchedGeometryEffectModifier.swift index 6bfd1f54c..79febd1f3 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MatchedGeometryEffectModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/MatchedGeometryEffectModifier.swift @@ -23,16 +23,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// matchedGeometryEffect(id: attr("id"), in: attr("namespace"), properties: .frame, anchor: .center, isSource: attr("isSource")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/PrefersDefaultFocusModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/PrefersDefaultFocusModifier.swift index ae4e1a23a..a20bc568b 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/PrefersDefaultFocusModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/PrefersDefaultFocusModifier.swift @@ -18,16 +18,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// prefersDefaultFocus(attr("prefersDefaultFocus"), in: attr("namespace")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/Rotation3DEffectModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/Rotation3DEffectModifier.swift index 3aba37513..577948ff7 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/Rotation3DEffectModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/Rotation3DEffectModifier.swift @@ -23,16 +23,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// rotation3DEffect(.zero, axis: (x: 1, y: 0, z: 0), anchor: .center, anchorZ: attr("anchorZ"), perspective: attr("perspective")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchCompletionModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchCompletionModifier.swift index a4328cc96..e33d86657 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchCompletionModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchCompletionModifier.swift @@ -19,11 +19,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// searchCompletion(:token) -/// end +/// ```html +/// /// ``` /// /// ### searchCompletion(_:) @@ -33,16 +30,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// searchCompletion(attr("completion")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchScopesModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchScopesModifier.swift index 42882081b..02f8112dc 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchScopesModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Overrides/SearchScopesModifier.swift @@ -21,22 +21,13 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// searchScopes(attr("scope"), activation: .automatic, scopes: :scopes) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// /// /// ``` /// /// ```elixir -/// # LiveView /// def handle_event("scope", params, socket) /// ``` /// @@ -48,22 +39,13 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// searchScopes(attr("scope"), scopes: :scopes) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// /// /// ``` /// /// ```elixir -/// # LiveView /// def handle_event("scope", params, socket) /// ``` @_documentation(visibility: public) diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/FillModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/FillModifier.swift index c157b3234..5acf4a3eb 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/FillModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/FillModifier.swift @@ -29,16 +29,16 @@ import LiveViewNativeStylesheet struct _FillModifier: ShapeFinalizerModifier { static let name = "fill" - let content: AnyShapeStyle + let content: AnyShapeStyle.Resolvable let style: FillStyle - init(_ content: AnyShapeStyle = .init(.foreground), style: FillStyle = .init()) { + init(_ content: AnyShapeStyle.Resolvable = .init(.foreground), style: FillStyle = .init()) { self.content = content self.style = style } @ViewBuilder func apply(to shape: AnyShape, on element: ElementNode) -> some View { - shape.fill(content, style: style) + shape.fill(content.resolve(on: element), style: style) } } diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/ScaleModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/ScaleModifier.swift index b02829786..c9ac5e1a3 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/ScaleModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/ScaleModifier.swift @@ -19,16 +19,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// scale(x: attr("x"), y: attr("y"), anchor: .center) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/StrokeModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/StrokeModifier.swift index 29dd42e30..3edf22c33 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/StrokeModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Shapes/StrokeModifier.swift @@ -19,16 +19,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// stroke(AnyShapeStyle, style: StrokeStyle, antialiased: attr("antialiased")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` /// /// ### stroke(_:lineWidth:antialiased:) @@ -40,16 +32,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// stroke(AnyShapeStyle, lineWidth: attr("lineWidth"), antialiased: attr("antialiased")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression @@ -57,20 +41,17 @@ struct _StrokeModifier: ShapeFinalizerModifier { static var name: String { "stroke" } enum Storage { - case _0(content: AnyShapeStyle, style: StrokeStyle, antialiased: AttributeReference) - case _1(content: AnyShapeStyle, lineWidth: AttributeReference, antialiased: AttributeReference) + case _0(content: AnyShapeStyle.Resolvable, style: StrokeStyle, antialiased: AttributeReference) + case _1(content: AnyShapeStyle.Resolvable, lineWidth: AttributeReference, antialiased: AttributeReference) } let storage: Storage - @ObservedElement private var element - @LiveContext private var context - - init(_ content: AnyShapeStyle, style: StrokeStyle, antialiased: AttributeReference = .init(storage: .constant(true))) { + init(_ content: AnyShapeStyle.Resolvable, style: StrokeStyle, antialiased: AttributeReference = .init(storage: .constant(true))) { self.storage = ._0(content: content, style: style, antialiased: antialiased) } - init(_ content: AnyShapeStyle, lineWidth: AttributeReference = .init(storage: .constant(1)), antialiased: AttributeReference = .init(storage: .constant(true))) { + init(_ content: AnyShapeStyle.Resolvable, lineWidth: AttributeReference = .init(storage: .constant(1)), antialiased: AttributeReference = .init(storage: .constant(true))) { self.storage = ._1(content: content, lineWidth: lineWidth, antialiased: antialiased) } @@ -79,15 +60,15 @@ struct _StrokeModifier: ShapeFinalizerModifier { switch storage { case ._0(let content, let style, let antialiased): if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { - shape.stroke(content, style: style, antialiased: antialiased.resolve(on: element, in: context)) + shape.stroke(content.resolve(on: element), style: style, antialiased: antialiased.resolve(on: element)) } else { - shape.stroke(content, style: style) + shape.stroke(content.resolve(on: element), style: style) } case ._1(let content, let lineWidth, let antialiased): if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { - shape.stroke(content, lineWidth: lineWidth.resolve(on: element, in: context), antialiased: antialiased.resolve(on: element, in: context)) + shape.stroke(content.resolve(on: element), lineWidth: lineWidth.resolve(on: element), antialiased: antialiased.resolve(on: element)) } else { - shape.stroke(content, lineWidth: lineWidth.resolve(on: element, in: context)) + shape.stroke(content.resolve(on: element), lineWidth: lineWidth.resolve(on: element)) } } } diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BaselineOffsetModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BaselineOffsetModifier.swift index 328cb75c0..86de826fa 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BaselineOffsetModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BaselineOffsetModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// baselineOffset(attr("baselineOffset")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BoldModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BoldModifier.swift index cf749d782..78065d065 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BoldModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/BoldModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// bold(attr("isActive")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ForegroundStyleModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ForegroundStyleModifier.swift index 9cfa4f989..ef6f92a83 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ForegroundStyleModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ForegroundStyleModifier.swift @@ -58,33 +58,35 @@ struct _ForegroundStyleModifier: TextModifier { static var name: String { "foregroundStyle" } enum Value { - case _0(style: AnyShapeStyle) - case _1(primary: AnyShapeStyle, secondary: AnyShapeStyle) - case _2(primary: AnyShapeStyle, secondary: AnyShapeStyle, tertiary: AnyShapeStyle) + case _0(style: AnyShapeStyle.Resolvable) + case _1(primary: AnyShapeStyle.Resolvable, secondary: AnyShapeStyle.Resolvable) + case _2(primary: AnyShapeStyle.Resolvable, secondary: AnyShapeStyle.Resolvable, tertiary: AnyShapeStyle.Resolvable) } let value: Value - init(_ style: AnyShapeStyle) { + @ObservedElement private var element + + init(_ style: AnyShapeStyle.Resolvable) { self.value = ._0(style: style) } - init(_ primary: AnyShapeStyle, _ secondary: AnyShapeStyle) { + init(_ primary: AnyShapeStyle.Resolvable, _ secondary: AnyShapeStyle.Resolvable) { self.value = ._1(primary: primary, secondary: secondary) } - init(_ primary: AnyShapeStyle, _ secondary: AnyShapeStyle, _ tertiary: AnyShapeStyle) { + init(_ primary: AnyShapeStyle.Resolvable, _ secondary: AnyShapeStyle.Resolvable, _ tertiary: AnyShapeStyle.Resolvable) { self.value = ._2(primary: primary, secondary: secondary, tertiary: tertiary) } func body(content: Content) -> some View { switch value { case let ._0(style): - content.foregroundStyle(style) + content.foregroundStyle(style.resolve(on: element)) case let ._1(primary, secondary): - content.foregroundStyle(primary, secondary) + content.foregroundStyle(primary.resolve(on: element), secondary.resolve(on: element)) case let ._2(primary, secondary, tertiary): - content.foregroundStyle(primary, secondary, tertiary) + content.foregroundStyle(primary.resolve(on: element), secondary.resolve(on: element), tertiary.resolve(on: element)) } } @@ -92,7 +94,7 @@ struct _ForegroundStyleModifier: TextModifier { if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), case let ._0(style) = value { - return text.foregroundStyle(style) + return text.foregroundStyle(style.resolve(on: element)) } else { return text } diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ItalicModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ItalicModifier.swift index 9362ff665..168d0403d 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ItalicModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/ItalicModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// italic(attr("isActive")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/KerningModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/KerningModifier.swift index 84f511620..dc343fa16 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/KerningModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/KerningModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// kerning(attr("kerning")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/MonospacedModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/MonospacedModifier.swift index 58f8d8d84..f66932765 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/MonospacedModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/MonospacedModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// monospaced(attr("isActive")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/StrikethroughModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/StrikethroughModifier.swift index 385142c3c..f7da6fd3b 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/StrikethroughModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/StrikethroughModifier.swift @@ -19,16 +19,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// strikethrough(attr("isActive"), pattern: .solid, color: attr("color")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression @@ -37,7 +29,7 @@ struct _StrikethroughModifier: TextModifier { let isActive: AttributeReference let pattern: SwiftUI.Text.LineStyle.Pattern - let color: AttributeReference? + let color: Color.Resolvable? @ObservedElement private var element @LiveContext private var context @@ -45,7 +37,7 @@ struct _StrikethroughModifier: TextModifier { init( _ isActive: AttributeReference = .init(storage: .constant(true)), pattern: SwiftUI.Text.LineStyle.Pattern = .solid, - color: AttributeReference? = .init(storage: .constant(nil)) + color: Color.Resolvable? = nil ) { self.isActive = isActive self.pattern = pattern diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TextScaleModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TextScaleModifier.swift index 1c72d95bc..b72ed7a81 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TextScaleModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TextScaleModifier.swift @@ -18,16 +18,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// textScale(.default, isEnabled: attr("isEnabled")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TrackingModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TrackingModifier.swift index 23c216c32..60b8ea00f 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TrackingModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/TrackingModifier.swift @@ -17,16 +17,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// tracking(attr("tracking")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression diff --git a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/UnderlineModifier.swift b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/UnderlineModifier.swift index 68a7dacaa..f31ed5d2c 100644 --- a/Sources/LiveViewNative/Stylesheets/Modifiers/Text/UnderlineModifier.swift +++ b/Sources/LiveViewNative/Stylesheets/Modifiers/Text/UnderlineModifier.swift @@ -19,16 +19,8 @@ import LiveViewNativeStylesheet /// /// Example: /// -/// ```elixir -/// # stylesheet -/// "example" do -/// underline(attr("isActive"), pattern: .solid, color: attr("color")) -/// end -/// ``` -/// /// ```heex -/// <%!-- template --%> -/// +/// /// ``` @_documentation(visibility: public) @ParseableExpression @@ -37,7 +29,7 @@ struct _UnderlineModifier: ViewModifier { let isActive: AttributeReference let pattern: SwiftUI.Text.LineStyle.Pattern - let color: AttributeReference? + let color: Color.Resolvable? @ObservedElement private var element @LiveContext private var context @@ -45,7 +37,7 @@ struct _UnderlineModifier: ViewModifier { init( _ isActive: AttributeReference = .init(storage: .constant(true)), pattern: SwiftUI.Text.LineStyle.Pattern = .solid, - color: AttributeReference? = .init(storage: .constant(nil)) + color: Color.Resolvable? = nil ) { self.isActive = isActive self.pattern = pattern diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ListItemTint+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ListItemTint+ParseableModifierValue.swift index 9f60084a0..02df626d8 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ListItemTint+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ListItemTint+ParseableModifierValue.swift @@ -15,36 +15,57 @@ import LiveViewNativeStylesheet /// - `.fixed(Color)`, with a ``SwiftUI/Color`` /// - `.preferred(Color)`, with a ``SwiftUI/Color`` @_documentation(visibility: public) -extension ListItemTint: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - ImplicitStaticMember { - OneOf { - ConstantAtomLiteral("monochrome").map({ Self.monochrome }) - Fixed.parser(in: context).map({ Self.fixed($0.tint) }) - Preferred.parser(in: context).map({ Self.preferred($0.tint) }) +extension ListItemTint { + struct Resolvable: ParseableModifierValue { + public static func parser(in context: ParseableModifierContext) -> some Parser { + ImplicitStaticMember { + OneOf { + ConstantAtomLiteral("monochrome").map({ Self(storage: .monochrome) }) + Fixed.parser(in: context).map({ Self(storage: .fixed($0.tint)) }) + Preferred.parser(in: context).map({ Self(storage: .preferred($0.tint)) }) + } } } - } - - @ParseableExpression - struct Fixed { - static let name = "fixed" - let tint: Color + enum Storage { + case monochrome + case fixed(Color.Resolvable) + case preferred(Color.Resolvable) + } + + let storage: Storage - init(_ tint: Color) { - self.tint = tint + func resolve(on element: ElementNode, in context: LiveContext) -> ListItemTint { + switch storage { + case .monochrome: + .monochrome + case .fixed(let fixed): + .fixed(fixed.resolve(on: element, in: context)) + case .preferred(let preferred): + .preferred(preferred.resolve(on: element, in: context)) + } } - } - - @ParseableExpression - struct Preferred { - static let name = "preferred" - let tint: Color + @ParseableExpression + struct Fixed { + static let name = "fixed" + + let tint: Color.Resolvable + + init(_ tint: Color.Resolvable) { + self.tint = tint + } + } - init(_ tint: Color) { - self.tint = tint + @ParseableExpression + struct Preferred { + static let name = "preferred" + + let tint: Color.Resolvable + + init(_ tint: Color.Resolvable) { + self.tint = tint + } } } } diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/AnyShapeStyle+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/AnyShapeStyle+ParseableModifierValue.swift index 1b226a469..06cdf9125 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/AnyShapeStyle+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/AnyShapeStyle+ParseableModifierValue.swift @@ -138,316 +138,536 @@ import LiveViewNativeStylesheet /// .blue.shadow(.inner(radius: 8, y: 8)) /// ``` @_documentation(visibility: public) -extension AnyShapeStyle: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - OneOf { - ChainedMemberExpression { - baseParser(in: context) - } member: { - StyleModifier.parser(in: context) - } - .map({ (base: any ShapeStyle, members: [StyleModifier]) in - (base: base, members: members) - }) - _ColorParser(context: context) { - StyleModifier.parser(in: context) - } - .map({ (base: SwiftUI.Color, members: [StyleModifier]) in - (base: base as any ShapeStyle, members: members) - }) - } - .map({ (base: any ShapeStyle, modifiers: [StyleModifier]) in - var result = base - for modifier in modifiers { - result = modifier.apply(to: result) - } - return AnyShapeStyle(result) - }) - } - - static func baseParser(in context: ParseableModifierContext) -> some Parser { - OneOf { - HierarchicalShapeStyle.parser(in: context).map({ $0 as any ShapeStyle }) +public extension AnyShapeStyle { + struct Resolvable: ParseableModifierValue { + enum Storage { + case value(AnyShapeStyle) + case color(Color.Resolvable) - Material.parser(in: context).map({ $0 as any ShapeStyle }) + case gradient(Gradient.Resolvable) + case anyGradient(AnyGradient.Resolvable) - ConstantAtomLiteral("foreground").map({ ForegroundStyle() as any ShapeStyle }) - ConstantAtomLiteral("background").map({ BackgroundStyle() as any ShapeStyle }) - #if !os(watchOS) && !os(tvOS) - ConstantAtomLiteral("selection").map({ SelectionShapeStyle() as any ShapeStyle }) - #endif - ConstantAtomLiteral("tint").map({ TintShapeStyle() as any ShapeStyle }) - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { - ConstantAtomLiteral("separator").map({ SeparatorShapeStyle() as any ShapeStyle }) - ConstantAtomLiteral("placeholder").map({ PlaceholderTextShapeStyle() as any ShapeStyle }) - ConstantAtomLiteral("link").map({ LinkShapeStyle() as any ShapeStyle }) - ConstantAtomLiteral("fill").map({ FillShapeStyle() as any ShapeStyle }) - } - #if !os(visionOS) - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - ConstantAtomLiteral("windowBackground").map({ WindowBackgroundShapeStyle() as any ShapeStyle }) - } - #endif - - ImagePaint.parser(in: context).map({ $0 as any ShapeStyle }) - _image.parser(in: context).map(\.value) - - Gradient.parser(in: context).map({ $0 as any ShapeStyle }) - AnyGradient.parser(in: context).map({ $0 as any ShapeStyle }) - - AngularGradient.parser(in: context).map({ $0 as any ShapeStyle }) - _angularGradient.parser(in: context).map(\.value) - _conicGradient.parser(in: context).map(\.value) - - EllipticalGradient.parser(in: context).map({ $0 as any ShapeStyle }) - _ellipticalGradient.parser(in: context).map(\.value) + case angularGradient(_angularGradient) + case conicGradient(_conicGradient) + case ellipticalGradient(_ellipticalGradient) + case linearGradient(_linearGradient) + case radialGradient(_radialGradient) - LinearGradient.parser(in: context).map({ $0 as any ShapeStyle }) - _linearGradient.parser(in: context).map(\.value) + case modifier(StyleModifier) - RadialGradient.parser(in: context).map({ $0 as any ShapeStyle }) - _radialGradient.parser(in: context).map(\.value) - - StyleModifier.parser(in: context).map(\.value) - } - } - - @ParseableExpression - struct _angularGradient { - static let name = "angularGradient" - - let value: any ShapeStyle - - init(_ gradient: AnyGradient, center: UnitPoint = .center, startAngle: Angle, endAngle: Angle) { - self.value = AngularGradient.angularGradient(gradient, center: center, startAngle: startAngle, endAngle: endAngle) - } - - init(_ gradient: Gradient, center: UnitPoint = .center, startAngle: Angle, endAngle: Angle) { - self.value = AngularGradient.angularGradient(gradient, center: center, startAngle: startAngle, endAngle: endAngle) - } - - init(colors: [Color], center: UnitPoint, startAngle: Angle, endAngle: Angle) { - self.value = AngularGradient.angularGradient(colors: colors, center: center, startAngle: startAngle, endAngle: endAngle) - } - - init(stops: [Gradient.Stop], center: UnitPoint, startAngle: Angle, endAngle: Angle) { - self.value = AngularGradient.angularGradient(stops: stops, center: center, startAngle: startAngle, endAngle: endAngle) - } - } - - @ParseableExpression - struct _conicGradient { - static let name = "conicGradient" - - let value: any ShapeStyle - - init(_ gradient: AnyGradient, center: UnitPoint = .center, angle: Angle = .zero) { - self.value = AngularGradient.conicGradient(gradient, center: center, angle: angle) - } - - init(_ gradient: Gradient, center: UnitPoint, angle: Angle = .zero) { - self.value = AngularGradient.conicGradient(gradient, center: center, angle: angle) - } - - init(colors: [Color], center: UnitPoint, angle: Angle = .zero) { - self.value = AngularGradient.conicGradient(colors: colors, center: center, angle: angle) - } - - init(stops: [Gradient.Stop], center: UnitPoint, angle: Angle = .zero) { - self.value = AngularGradient.conicGradient(stops: stops, center: center, angle: angle) - } - } - - @ParseableExpression - struct _ellipticalGradient { - static let name = "ellipticalGradient" - - let value: any ShapeStyle - - init(_ gradient: Gradient, center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = EllipticalGradient.ellipticalGradient(gradient, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) - } - - init(colors: [Color], center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = EllipticalGradient.ellipticalGradient(colors: colors, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + init(_ style: some ShapeStyle) { + self = .value(AnyShapeStyle(style)) + } } - init(stops: [Gradient.Stop], center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = EllipticalGradient.ellipticalGradient(stops: stops, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + let storage: Storage + let modifiers: [StyleModifier] + + public func resolve(on element: ElementNode) -> AnyShapeStyle { + let base: any ShapeStyle = switch storage { + case .value(let value): + value + case .color(let color): + color.resolve(on: element) + case let .gradient(gradient): + gradient.resolve(on: element) + case let .anyGradient(gradient): + gradient.resolve(on: element) + case let .angularGradient(gradient): + gradient.resolve(on: element) + case let .conicGradient(gradient): + gradient.resolve(on: element) + case let .ellipticalGradient(gradient): + gradient.resolve(on: element) + case let .linearGradient(gradient): + gradient.resolve(on: element) + case let .radialGradient(gradient): + gradient.resolve(on: element) + case let .modifier(modifier): + modifier.resolve(on: element) + } + return modifiers.reduce(AnyShapeStyle(base)) { + AnyShapeStyle($1.apply(to: $0, on: element)) + } } - init(_ gradient: AnyGradient, center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = EllipticalGradient.ellipticalGradient(gradient, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + public func resolve(on element: ElementNode, in context: LiveContext) -> AnyShapeStyle { + resolve(on: element) } - } - - @ParseableExpression - struct _linearGradient { - static let name = "linearGradient" - let value: any ShapeStyle - - init(_ gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = LinearGradient.linearGradient(gradient, startPoint: startPoint, endPoint: endPoint) + public init(_ constant: AnyShapeStyle) { + self.storage = .value(constant) + self.modifiers = [] } - init(colors: [Color], startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = LinearGradient.linearGradient(colors: colors, startPoint: startPoint, endPoint: endPoint) + public init(_ constant: some ShapeStyle) { + self.storage = .value(AnyShapeStyle(constant)) + self.modifiers = [] } - init(stops: [Gradient.Stop], startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = LinearGradient.linearGradient(stops: stops, startPoint: startPoint, endPoint: endPoint) + init(storage: Storage, modifiers: [StyleModifier]) { + self.storage = storage + self.modifiers = modifiers } - init(_ gradient: AnyGradient, startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = LinearGradient.linearGradient(gradient, startPoint: startPoint, endPoint: endPoint) - } - } - - @ParseableExpression - struct _radialGradient { - static let name = "radialGradient" - - let value: any ShapeStyle - - init(_ gradient: Gradient, center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = RadialGradient.radialGradient(gradient, center: center, startRadius: startRadius, endRadius: endRadius) + public static func parser(in context: ParseableModifierContext) -> some Parser { + OneOf { + _ColorParser(context: context) { + StyleModifier.parser(in: context) + } + .map({ (base: Color.Resolvable, members: [StyleModifier]) in + Self(storage: .color(base), modifiers: members) + }) + ChainedMemberExpression { + baseParser(in: context) + } member: { + StyleModifier.parser(in: context) + } + .map({ (base: Storage, members: [StyleModifier]) in + Self(storage: base, modifiers: members) + }) + } } - init(colors: [Color], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = RadialGradient.radialGradient(colors: colors, center: center, startRadius: startRadius, endRadius: endRadius) + static func baseParser(in context: ParseableModifierContext) -> some Parser { + BaseParser(context: context) } - init(stops: [Gradient.Stop], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = RadialGradient.radialGradient(stops: stops, center: center, startRadius: startRadius, endRadius: endRadius) + private struct BaseParser: Parser { + let context: ParseableModifierContext + + func parse(_ input: inout Substring.UTF8View) throws -> Storage { + var parsers: [AnyParser] = [ + Color.Resolvable.parser(in: context).map(Storage.color).eraseToAnyParser(), + HierarchicalShapeStyle.parser(in: context).map({ Storage($0) }).eraseToAnyParser(), + + Material.parser(in: context).map({ Storage($0) }).eraseToAnyParser(), + + ConstantAtomLiteral("foreground").map({ Storage(ForegroundStyle()) }).eraseToAnyParser(), + ConstantAtomLiteral("background").map({ Storage(BackgroundStyle()) }).eraseToAnyParser(), + + ImagePaint.parser(in: context).map({ Storage($0) }).eraseToAnyParser(), + _image.parser(in: context).map({ Storage($0.value) }).eraseToAnyParser(), + + Gradient.Resolvable.parser(in: context).map({ Storage.gradient($0) }).eraseToAnyParser(), + AnyGradient.Resolvable.parser(in: context).map({ Storage.anyGradient($0) }).eraseToAnyParser(), + + _angularGradient.parser(in: context).map({ Storage.angularGradient($0) }).eraseToAnyParser(), + _conicGradient.parser(in: context).map({ Storage.conicGradient($0) }).eraseToAnyParser(), + + _ellipticalGradient.parser(in: context).map({ Storage.ellipticalGradient($0) }).eraseToAnyParser(), + + _linearGradient.parser(in: context).map({ Storage.linearGradient($0) }).eraseToAnyParser(), + + _radialGradient.parser(in: context).map({ Storage.radialGradient($0) }).eraseToAnyParser(), + + StyleModifier.parser(in: context).map({ Storage.modifier($0) }).eraseToAnyParser(), + ] +#if !os(watchOS) && !os(tvOS) + parsers.append(ConstantAtomLiteral("selection").map({ Storage(SelectionShapeStyle()) }).eraseToAnyParser()) +#endif + parsers.append(ConstantAtomLiteral("tint").map({ Storage(TintShapeStyle()) }).eraseToAnyParser()) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { + parsers.append(ConstantAtomLiteral("separator").map({ Storage(SeparatorShapeStyle()) }).eraseToAnyParser()) + parsers.append(ConstantAtomLiteral("placeholder").map({ Storage(PlaceholderTextShapeStyle()) }).eraseToAnyParser()) + parsers.append(ConstantAtomLiteral("link").map({ Storage(LinkShapeStyle()) }).eraseToAnyParser()) + parsers.append(ConstantAtomLiteral("fill").map({ Storage(FillShapeStyle()) }).eraseToAnyParser()) + } +#if !os(visionOS) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + parsers.append(ConstantAtomLiteral("windowBackground").map({ Storage(WindowBackgroundShapeStyle()) }).eraseToAnyParser()) + } +#endif + + let copy = input + for parser in parsers { + if let value = try? parser.parse(&input) { + return value + } else { + input = copy + } + } + throw ArgumentParseError.unknownArgument("AnyShapeStyle") + } } - init(_ gradient: AnyGradient, center: UnitPoint = .center, startRadius: CGFloat = 0, endRadius: CGFloat) { - self.value = RadialGradient.radialGradient(gradient, center: center, startRadius: startRadius, endRadius: endRadius) + @ParseableExpression + enum _angularGradient { + static let name = "angularGradient" + + case anyGradient(gradient: AnyGradient.Resolvable, center: AttributeReference, startAngle: AttributeReference, endAngle: AttributeReference) + case gradient(gradient: Gradient.Resolvable, center: AttributeReference, startAngle: AttributeReference, endAngle: AttributeReference) + case colors(colors: [Color.Resolvable], center: AttributeReference, startAngle: AttributeReference, endAngle: AttributeReference) + case stops(stops: [Gradient.Stop.Resolvable], center: AttributeReference, startAngle: AttributeReference, endAngle: AttributeReference) + + init( + _ gradient: AnyGradient.Resolvable, + center: AttributeReference = .init(.center), + startAngle: AttributeReference, + endAngle: AttributeReference + ) { + self = .anyGradient(gradient: gradient, center: center, startAngle: startAngle, endAngle: endAngle) + } + + init( + _ gradient: Gradient.Resolvable, + center: AttributeReference = .init(.center), + startAngle: AttributeReference, + endAngle: AttributeReference + ) { + self = .gradient(gradient: gradient, center: center, startAngle: startAngle, endAngle: endAngle) + } + + init( + colors: [Color.Resolvable], + center: AttributeReference, + startAngle: AttributeReference, + endAngle: AttributeReference + ) { + self = .colors(colors: colors, center: center, startAngle: startAngle, endAngle: endAngle) + } + + init( + stops: [Gradient.Stop.Resolvable], + center: AttributeReference = .init(.center), + startAngle: AttributeReference, + endAngle: AttributeReference + ) { + self = .stops(stops: stops, center: center, startAngle: startAngle, endAngle: endAngle) + } + + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case .anyGradient(let gradient, let center, let startAngle, let endAngle): + AngularGradient.angularGradient(gradient.resolve(on: element), center: center.resolve(on: element), startAngle: startAngle.resolve(on: element), endAngle: endAngle.resolve(on: element)) + case .gradient(let gradient, let center, let startAngle, let endAngle): + AngularGradient.angularGradient(gradient.resolve(on: element), center: center.resolve(on: element), startAngle: startAngle.resolve(on: element), endAngle: endAngle.resolve(on: element)) + case .colors(let colors, let center, let startAngle, let endAngle): + AngularGradient.angularGradient(colors: colors.map({ $0.resolve(on: element) }), center: center.resolve(on: element), startAngle: startAngle.resolve(on: element), endAngle: endAngle.resolve(on: element)) + case .stops(let stops, let center, let startAngle, let endAngle): + AngularGradient.angularGradient(stops: stops.map({ $0.resolve(on: element) }), center: center.resolve(on: element), startAngle: startAngle.resolve(on: element), endAngle: endAngle.resolve(on: element)) + } + } } - } - - @ParseableExpression - struct _image { - static let name = "image" - - let value: any ShapeStyle - init(_ image: Image, sourceRect: CGRect = .init(x: 0, y: 0, width: 1, height: 1), scale: CGFloat = 1) { - self.value = ImagePaint.image(image, sourceRect: sourceRect, scale: scale) + @ParseableExpression + enum _conicGradient { + static let name = "conicGradient" + + case anyGradient(gradient: AnyGradient.Resolvable, center: AttributeReference, angle: AttributeReference) + case gradient(gradient: Gradient.Resolvable, center: AttributeReference, angle: AttributeReference) + case colors(colors: [Color.Resolvable], center: AttributeReference, angle: AttributeReference) + case stops(stops: [Gradient.Stop.Resolvable], center: AttributeReference, angle: AttributeReference) + + init(_ gradient: AnyGradient.Resolvable, center: AttributeReference = .init(.center), angle: AttributeReference = .init(.zero)) { + self = .anyGradient(gradient: gradient, center: center, angle: angle) + } + + init(_ gradient: Gradient.Resolvable, center: AttributeReference, angle: AttributeReference = .init(.zero)) { + self = .gradient(gradient: gradient, center: center, angle: angle) + } + + init(colors: [Color.Resolvable], center: AttributeReference, angle: AttributeReference = .init(.zero)) { + self = .colors(colors: colors, center: center, angle: angle) + } + + init(stops: [Gradient.Stop.Resolvable], center: AttributeReference, angle: AttributeReference = .init(.zero)) { + self = .stops(stops: stops, center: center, angle: angle) + } + + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case .anyGradient(let gradient, let center, let angle): + AngularGradient.conicGradient(gradient.resolve(on: element), center: center.resolve(on: element), angle: angle.resolve(on: element)) + case .gradient(let gradient, let center, let angle): + AngularGradient.conicGradient(gradient.resolve(on: element), center: center.resolve(on: element), angle: angle.resolve(on: element)) + case .colors(let colors, let center, let angle): + AngularGradient.conicGradient(colors: colors.map({ $0.resolve(on: element) }), center: center.resolve(on: element), angle: angle.resolve(on: element)) + case .stops(let stops, let center, let angle): + AngularGradient.conicGradient(stops: stops.map({ $0.resolve(on: element) }), center: center.resolve(on: element), angle: angle.resolve(on: element)) + } + } } - } - - enum StyleModifier: ParseableModifierValue { - case blendMode(_blendMode) - case opacity(_opacity) - case shadow(_shadow) - case hierarchical(HierarchicalLevel) - static func parser(in context: ParseableModifierContext) -> some Parser { - OneOf { - _blendMode.parser(in: context).map(Self.blendMode) - _opacity.parser(in: context).map(Self.opacity) - _shadow.parser(in: context).map(Self.shadow) - HierarchicalLevel.parser(in: context).map(Self.hierarchical) + @ParseableExpression + enum _ellipticalGradient { + static let name = "ellipticalGradient" + + case gradient(gradient: Gradient.Resolvable, center: AttributeReference, startRadiusFraction: AttributeReference, endRadiusFraction: AttributeReference) + case colors(colors: [Color.Resolvable], center: AttributeReference, startRadiusFraction: AttributeReference, endRadiusFraction: AttributeReference) + case stops(stops: [Gradient.Stop.Resolvable], center: AttributeReference, startRadiusFraction: AttributeReference, endRadiusFraction: AttributeReference) + case anyGradient(gradient: AnyGradient.Resolvable, center: AttributeReference, startRadiusFraction: AttributeReference, endRadiusFraction: AttributeReference) + + init(_ gradient: Gradient.Resolvable, center: AttributeReference = .init(.center), startRadiusFraction: AttributeReference = .init(0), endRadiusFraction: AttributeReference = .init(0.5)) { + self = .gradient(gradient: gradient, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + } + + init(colors: [Color.Resolvable], center: AttributeReference = .init(.center), startRadiusFraction: AttributeReference = .init(0), endRadiusFraction: AttributeReference = .init(0.5)) { + self = .colors(colors: colors, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + } + + init(stops: [Gradient.Stop.Resolvable], center: AttributeReference = .init(.center), startRadiusFraction: AttributeReference = .init(0), endRadiusFraction: AttributeReference = .init(0.5)) { + self = .stops(stops: stops, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + } + + init(_ gradient: AnyGradient.Resolvable, center: AttributeReference = .init(.center), startRadiusFraction: AttributeReference = .init(0), endRadiusFraction: AttributeReference = .init(0.5)) { + self = .anyGradient(gradient: gradient, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) + } + + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case .gradient(let gradient, let center, let startRadiusFraction, let endRadiusFraction): + EllipticalGradient.ellipticalGradient(gradient.resolve(on: element), center: center.resolve(on: element), startRadiusFraction: startRadiusFraction.resolve(on: element), endRadiusFraction: endRadiusFraction.resolve(on: element)) + case .colors(let colors, let center, let startRadiusFraction, let endRadiusFraction): + EllipticalGradient.ellipticalGradient(colors: colors.map({ $0.resolve(on: element) }), center: center.resolve(on: element), startRadiusFraction: startRadiusFraction.resolve(on: element), endRadiusFraction: endRadiusFraction.resolve(on: element)) + case .stops(let stops, let center, let startRadiusFraction, let endRadiusFraction): + EllipticalGradient.ellipticalGradient(stops: stops.map({ $0.resolve(on: element) }), center: center.resolve(on: element), startRadiusFraction: startRadiusFraction.resolve(on: element), endRadiusFraction: endRadiusFraction.resolve(on: element)) + case .anyGradient(let gradient, let center, let startRadiusFraction, let endRadiusFraction): + EllipticalGradient.ellipticalGradient(gradient.resolve(on: element), center: center.resolve(on: element), startRadiusFraction: startRadiusFraction.resolve(on: element), endRadiusFraction: endRadiusFraction.resolve(on: element)) + } } } @ParseableExpression - struct _blendMode { - static let name = "blendMode" + enum _linearGradient { + static let name = "linearGradient" - let value: BlendMode + case gradient(gradient: Gradient.Resolvable, startPoint: AttributeReference, endPoint: AttributeReference) + case colors(colors: [Color.Resolvable], startPoint: AttributeReference, endPoint: AttributeReference) + case stops(stops: [Gradient.Stop.Resolvable], startPoint: AttributeReference, endPoint: AttributeReference) + case anyGradient(gradient: AnyGradient.Resolvable, startPoint: AttributeReference, endPoint: AttributeReference) - init(_ value: BlendMode) { - self.value = value + init(_ gradient: Gradient.Resolvable, startPoint: AttributeReference, endPoint: AttributeReference) { + self = .gradient(gradient: gradient, startPoint: startPoint, endPoint: endPoint) + } + + init(colors: [Color.Resolvable], startPoint: AttributeReference, endPoint: AttributeReference) { + self = .colors(colors: colors, startPoint: startPoint, endPoint: endPoint) + } + + init(stops: [Gradient.Stop.Resolvable], startPoint: AttributeReference, endPoint: AttributeReference) { + self = .stops(stops: stops, startPoint: startPoint, endPoint: endPoint) + } + + init(_ gradient: AnyGradient.Resolvable, startPoint: AttributeReference, endPoint: AttributeReference) { + self = .anyGradient(gradient: gradient, startPoint: startPoint, endPoint: endPoint) + } + + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case .gradient(let gradient, let startPoint, let endPoint): + LinearGradient.linearGradient(gradient.resolve(on: element), startPoint: startPoint.resolve(on: element), endPoint: endPoint.resolve(on: element)) + case .colors(let colors, let startPoint, let endPoint): + LinearGradient.linearGradient(colors: colors.map({ $0.resolve(on: element) }), startPoint: startPoint.resolve(on: element), endPoint: endPoint.resolve(on: element)) + case .stops(let stops, let startPoint, let endPoint): + LinearGradient.linearGradient(stops: stops.map({ $0.resolve(on: element) }), startPoint: startPoint.resolve(on: element), endPoint: endPoint.resolve(on: element)) + case .anyGradient(let gradient, let startPoint, let endPoint): + LinearGradient.linearGradient(gradient.resolve(on: element), startPoint: startPoint.resolve(on: element), endPoint: endPoint.resolve(on: element)) + } } } @ParseableExpression - struct _opacity { - static let name = "opacity" + enum _radialGradient { + static let name = "radialGradient" - let value: Double + case gradient(gradient: Gradient.Resolvable, center: AttributeReference, startRadius: AttributeReference, endRadius: AttributeReference) + case colors(colors: [Color.Resolvable], center: AttributeReference, startRadius: AttributeReference, endRadius: AttributeReference) + case stops(stops: [Gradient.Stop.Resolvable], center: AttributeReference, startRadius: AttributeReference, endRadius: AttributeReference) + case anyGradient(gradient: AnyGradient.Resolvable, center: AttributeReference, startRadius: AttributeReference, endRadius: AttributeReference) - init(_ value: Double) { - self.value = value + init( + _ gradient: Gradient.Resolvable, + center: AttributeReference, + startRadius: AttributeReference, + endRadius: AttributeReference + ) { + self = .gradient(gradient: gradient, center: center, startRadius: startRadius, endRadius: endRadius) + } + + init( + colors: [Color.Resolvable], + center: AttributeReference, + startRadius: AttributeReference, + endRadius: AttributeReference + ) { + self = .colors(colors: colors, center: center, startRadius: startRadius, endRadius: endRadius) + } + + init( + stops: [Gradient.Stop.Resolvable], + center: AttributeReference, + startRadius: AttributeReference, + endRadius: AttributeReference + ) { + self = .stops(stops: stops, center: center, startRadius: startRadius, endRadius: endRadius) + } + + init( + _ gradient: AnyGradient.Resolvable, + center: AttributeReference = .init(.center), + startRadius: AttributeReference = .init(0), + endRadius: AttributeReference + ) { + self = .anyGradient(gradient: gradient, center: center, startRadius: startRadius, endRadius: endRadius) + } + + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case .gradient(let gradient, let center, let startRadius, let endRadius): + RadialGradient.radialGradient( + gradient.resolve(on: element), + center: center.resolve(on: element), + startRadius: startRadius.resolve(on: element), + endRadius: endRadius.resolve(on: element) + ) + case .colors(let colors, let center, let startRadius, let endRadius): + RadialGradient.radialGradient( + colors: colors.map({ $0.resolve(on: element) }), + center: center.resolve(on: element), + startRadius: startRadius.resolve(on: element), + endRadius: endRadius.resolve(on: element) + ) + case .stops(let stops, let center, let startRadius, let endRadius): + RadialGradient.radialGradient( + stops: stops.map({ $0.resolve(on: element) }), + center: center.resolve(on: element), + startRadius: startRadius.resolve(on: element), + endRadius: endRadius.resolve(on: element) + ) + case .anyGradient(let gradient, let center, let startRadius, let endRadius): + RadialGradient.radialGradient( + gradient.resolve(on: element), + center: center.resolve(on: element), + startRadius: startRadius.resolve(on: element), + endRadius: endRadius.resolve(on: element) + ) + } } } @ParseableExpression - struct _shadow { - static let name = "shadow" + struct _image { + static let name = "image" - let value: ShadowStyle + let value: any ShapeStyle - init(_ value: ShadowStyle) { - self.value = value + init(_ image: Image, sourceRect: CGRect = .init(x: 0, y: 0, width: 1, height: 1), scale: CGFloat = 1) { + self.value = ImagePaint.image(image, sourceRect: sourceRect, scale: scale) } } - enum HierarchicalLevel: String, CaseIterable, ParseableModifierValue { - typealias _ParserType = EnumParser + enum StyleModifier: ParseableModifierValue { + case blendMode(_blendMode) + case opacity(_opacity) + case shadow(_shadow) + case hierarchical(HierarchicalLevel) - static func parser(in context: ParseableModifierContext) -> EnumParser { - .init(Dictionary(uniqueKeysWithValues: Self.allCases.map({ ($0.rawValue, $0) }))) + static func parser(in context: ParseableModifierContext) -> some Parser { + OneOf { + _blendMode.parser(in: context).map(Self.blendMode) + _opacity.parser(in: context).map(Self.opacity) + _shadow.parser(in: context).map(Self.shadow) + HierarchicalLevel.parser(in: context).map(Self.hierarchical) + } } - case secondary - case tertiary - case quaternary - case quinary - } - - /// Apply this modifier to an existing `ShapeStyle`. - func apply(to style: some ShapeStyle) -> any ShapeStyle { - switch self { - case let .blendMode(blendMode): - return style.blendMode(blendMode.value) - case let .opacity(opacity): - return style.opacity(opacity.value) - case let .shadow(shadow): - return style.shadow(shadow.value) - case let .hierarchical(level): - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { + @ParseableExpression + struct _blendMode { + static let name = "blendMode" + + let value: BlendMode + + init(_ value: BlendMode) { + self.value = value + } + } + + @ParseableExpression + struct _opacity { + static let name = "opacity" + + let value: Double + + init(_ value: Double) { + self.value = value + } + } + + @ParseableExpression + struct _shadow { + static let name = "shadow" + + let value: _ShadowStyle + + init(_ value: _ShadowStyle) { + self.value = value + } + } + + enum HierarchicalLevel: String, CaseIterable, ParseableModifierValue { + typealias _ParserType = EnumParser + + static func parser(in context: ParseableModifierContext) -> EnumParser { + .init(Dictionary(uniqueKeysWithValues: Self.allCases.map({ ($0.rawValue, $0) }))) + } + + case secondary + case tertiary + case quaternary + case quinary + } + + /// Apply this modifier to an existing `ShapeStyle`. + func apply(to style: some ShapeStyle, on element: ElementNode) -> any ShapeStyle { + switch self { + case let .blendMode(blendMode): + return style.blendMode(blendMode.value) + case let .opacity(opacity): + return style.opacity(opacity.value) + case let .shadow(shadow): + return style.shadow(shadow.value.resolve(on: element)) + case let .hierarchical(level): + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { + switch level { + case .secondary: + return style.secondary + case .tertiary: + return style.tertiary + case .quaternary: + return style.quaternary + case .quinary: + return style.quinary + } + } else { + return style + } + } + } + + /// Use this modifier itself as a `ShapeStyle`. SwiftUI will apply it to the foreground style. + func resolve(on element: ElementNode) -> any ShapeStyle { + switch self { + case let .blendMode(blendMode): + return AnyShapeStyle(.blendMode(blendMode.value)) + case let .opacity(opacity): + return AnyShapeStyle(.opacity(opacity.value)) + case let .shadow(shadow): + return AnyShapeStyle(.shadow(shadow.value.resolve(on: element))) + case let .hierarchical(level): switch level { case .secondary: - return style.secondary + return AnyShapeStyle(.secondary) case .tertiary: - return style.tertiary + return AnyShapeStyle(.tertiary) case .quaternary: - return style.quaternary - case .quinary: - return style.quinary - } - } else { - return style - } - } - } - - /// Use this modifier itself as a `ShapeStyle`. SwiftUI will apply it to the foreground style. - var value: any ShapeStyle { - switch self { - case let .blendMode(blendMode): - return AnyShapeStyle(.blendMode(blendMode.value)) - case let .opacity(opacity): - return AnyShapeStyle(.opacity(opacity.value)) - case let .shadow(shadow): - return AnyShapeStyle(.shadow(shadow.value)) - case let .hierarchical(level): - switch level { - case .secondary: - return AnyShapeStyle(.secondary) - case .tertiary: - return AnyShapeStyle(.tertiary) - case .quaternary: - return AnyShapeStyle(.quaternary) - case .quinary: - if #available(iOS 16, macOS 12, tvOS 17, watchOS 10, visionOS 1, *) { - return AnyShapeStyle(.quinary) - } else { return AnyShapeStyle(.quaternary) + case .quinary: + if #available(iOS 16, macOS 12, tvOS 17, watchOS 10, visionOS 1, *) { + return AnyShapeStyle(.quinary) + } else { + return AnyShapeStyle(.quaternary) + } } } } diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Color+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Color+ParseableModifierValue.swift index f0f3a93c0..7d45690f7 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Color+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Color+ParseableModifierValue.swift @@ -7,6 +7,7 @@ import SwiftUI import LiveViewNativeStylesheet +import LiveViewNativeCore /// See [`SwiftUI.Color`](https://developer.apple.com/documentation/swiftui/Color) for more details. /// @@ -63,74 +64,203 @@ import LiveViewNativeStylesheet /// Color("MyColor").opacity(0.8) /// ``` @_documentation(visibility: public) -extension SwiftUI.Color: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _ColorParser(context: context) {} - .map(\.base) - } - - @ParseableExpression - struct CustomColor { - static let name = "Color" +public extension SwiftUI.Color { + struct Resolvable: ParseableModifierValue { + enum Storage { + case reference(AttributeName) + case constant(SwiftUI.Color) + case named(AttributeReference) + case components( + colorSpace: AttributeReference = .init(storage: .constant(.sRGB)), + red: AttributeReference, + green: AttributeReference, + blue: AttributeReference, + opacity: AttributeReference = .init(storage: .constant(1)) + ) + case monochrome( + colorSpace: AttributeReference = .init(storage: .constant(.sRGB)), + white: AttributeReference, + opacity: AttributeReference = .init(storage: .constant(1)) + ) + case hsb( + hue: AttributeReference, + saturation: AttributeReference, + brightness: AttributeReference, + opacity: AttributeReference = .init(storage: .constant(1)) + ) + } - let value: Color + let storage: Storage + let modifiers: [ColorModifier] - init(_ name: String) { - self.value = .init(name, bundle: nil) + public init(_ constant: SwiftUI.Color) { + self.storage = .constant(constant) + self.modifiers = [] } - public init(_ colorSpace: Color.RGBColorSpace = .sRGB, red: Double, green: Double, blue: Double, opacity: Double = 1) { - self.value = .init(colorSpace, red: red, green: green, blue: blue, opacity: opacity) + init(storage: Storage, modifiers: [ColorModifier]) { + self.storage = storage + self.modifiers = modifiers } - public init(_ colorSpace: Color.RGBColorSpace = .sRGB, white: Double, opacity: Double = 1) { - self.value = .init(colorSpace, white: white, opacity: opacity) + + public func resolve(on element: ElementNode, in context: LiveContext) -> SwiftUI.Color { + resolve(on: element) } - public init(hue: Double, saturation: Double, brightness: Double, opacity: Double = 1) { - self.value = .init(hue: hue, saturation: saturation, brightness: brightness, opacity: opacity) + + public func resolve(on element: ElementNode) -> SwiftUI.Color { + let base = switch storage { + case let .reference(name): + try! element.attributeValue(SwiftUI.Color.self, for: name) + case let .constant(constant): + constant + case let .named(name): + SwiftUI.Color.init(name.resolve(on: element), bundle: nil) + case let .components(colorSpace, red, green, blue, opacity): + SwiftUI.Color( + colorSpace.resolve(on: element), + red: red.resolve(on: element), + green: green.resolve(on: element), + blue: blue.resolve(on: element), + opacity: opacity.resolve(on: element) + ) + case let .monochrome(colorSpace, white, opacity): + SwiftUI.Color( + colorSpace.resolve(on: element), + white: white.resolve(on: element), + opacity: opacity.resolve(on: element) + ) + case let .hsb(hue, saturation, brightness, opacity): + SwiftUI.Color( + hue: hue.resolve(on: element), + saturation: saturation.resolve(on: element), + brightness: brightness.resolve(on: element), + opacity: opacity.resolve(on: element) + ) + } + return modifiers.reduce(into: base) { + $0 = $1.apply(to: $0, on: element) + } } - } - - static var systemColors: [String:Color] { - [ - "red": .red, - "orange": .orange, - "yellow": .yellow, - "green": .green, - "mint": .mint, - "teal": .teal, - "cyan": .cyan, - "blue": .blue, - "indigo": .indigo, - "purple": .purple, - "pink": .pink, - "brown": .brown, - "white": .white, - "gray": .gray, - "black": .black, - "clear": .clear, - "primary": .primary, - "secondary": .secondary, - ] - } - - static func baseParser(in context: ParseableModifierContext) -> some Parser { - return OneOf { - MemberExpression { - ConstantAtomLiteral("Color") - } member: { - EnumParser(systemColors) + + public var constant: SwiftUI.Color { + switch storage { + case .reference: + Color.primary + case let .constant(constant): + constant + case let .named(name): + SwiftUI.Color.init(name.constant(default: ""), bundle: nil) + case let .components(colorSpace, red, green, blue, opacity): + SwiftUI.Color( + colorSpace.constant(default: .sRGB), + red: red.constant(default: 0), + green: green.constant(default: 0), + blue: blue.constant(default: 0), + opacity: opacity.constant(default: 1) + ) + case let .monochrome(colorSpace, white, opacity): + SwiftUI.Color( + colorSpace.constant(default: .sRGB), + white: white.constant(default: 0), + opacity: opacity.constant(default: 1) + ) + case let .hsb(hue, saturation, brightness, opacity): + SwiftUI.Color( + hue: hue.constant(default: 0), + saturation: saturation.constant(default: 0), + brightness: brightness.constant(default: 0), + opacity: opacity.constant(default: 1) + ) } - .map(\.member) + } + + public static func parser(in context: ParseableModifierContext) -> some Parser { + _ColorParser(context: context) {} + .map(\.base) + } + + @ParseableExpression + struct CustomColor { + static let name = "Color" - ImplicitStaticMember(systemColors) + let storage: Storage - CustomColor.parser(in: context).map(\.value) + init(_ name: AttributeReference) { + self.storage = .named(name) + } + + public init( + _ colorSpace: AttributeReference = .init(.sRGB), + red: AttributeReference, + green: AttributeReference, + blue: AttributeReference, + opacity: AttributeReference = .init(1) + ) { + self.storage = .components(colorSpace: colorSpace, red: red, green: green, blue: blue, opacity: opacity) + } + + public init( + _ colorSpace: AttributeReference = .init(.sRGB), + white: AttributeReference, + opacity: AttributeReference = .init(1) + ) { + self.storage = .monochrome(colorSpace: colorSpace, white: white, opacity: opacity) + } + + public init( + hue: AttributeReference, + saturation: AttributeReference, + brightness: AttributeReference, + opacity: AttributeReference = .init(1) + ) { + self.storage = .hsb(hue: hue, saturation: saturation, brightness: brightness, opacity: opacity) + } } - } - - static func modifierParser(in context: ParseableModifierContext) -> some Parser { - OneOf { - ColorModifier.Opacity.parser(in: context).map(ColorModifier.opacity) + + static var systemColors: [String:Self] { + [ + "red": .init(.red), + "orange": .init(.orange), + "yellow": .init(.yellow), + "green": .init(.green), + "mint": .init(.mint), + "teal": .init(.teal), + "cyan": .init(.cyan), + "blue": .init(.blue), + "indigo": .init(.indigo), + "purple": .init(.purple), + "pink": .init(.pink), + "brown": .init(.brown), + "white": .init(.white), + "gray": .init(.gray), + "black": .init(.black), + "clear": .init(.clear), + "primary": .init(.primary), + "secondary": .init(.secondary), + ] + } + + static func baseParser(in context: ParseableModifierContext) -> some Parser { + return OneOf { + AttributeName.parser(in: context).map({ Self(storage: .reference($0), modifiers: []) }) + + MemberExpression { + ConstantAtomLiteral("Color") + } member: { + EnumParser(systemColors) + } + .map(\.member) + + ImplicitStaticMember(systemColors) + + CustomColor.parser(in: context).map({ Self(storage: $0.storage, modifiers: []) }) + } + } + + static func modifierParser(in context: ParseableModifierContext) -> some Parser { + OneOf { + ColorModifier.Opacity.parser(in: context).map(ColorModifier.opacity) + } } } } @@ -139,7 +269,7 @@ struct _ColorParser: Parser where Members.Input == Substring.UT let context: ParseableModifierContext @ParserBuilder let members: Members - var body: some Parser { + var body: some Parser { OneOf { MemberExpression { OneOf { @@ -148,14 +278,14 @@ struct _ColorParser: Parser where Members.Input == Substring.UT } } member: { ChainedMemberExpression { - EnumParser(Color.systemColors) + EnumParser(Color.Resolvable.systemColors) } member: { OneOf { - Color.modifierParser(in: context).map(AnyColorModifier.colorModifier) + Color.Resolvable.modifierParser(in: context).map(AnyColorModifier.colorModifier) members.map(AnyColorModifier.member) } } - .map { base, modifiers -> (base: SwiftUI.Color, members: [Members.Output]) in + .map { base, modifiers -> (base: Color.Resolvable, members: [Members.Output]) in let colorModifiers: [ColorModifier] = modifiers .compactMap({ guard case let .colorModifier(modifier) = $0 else { return nil } @@ -166,23 +296,20 @@ struct _ColorParser: Parser where Members.Input == Substring.UT guard case let .member(member) = $0 else { return nil } return member }) - let color = colorModifiers.reduce(into: base) { - $0 = $1.apply(to: $0) - } - return (base: color, members: members) + return (base: .init(storage: base.storage, modifiers: base.modifiers + colorModifiers), members: members) } } .map(\.member) ChainedMemberExpression { - Color.baseParser(in: context) + Color.Resolvable.baseParser(in: context) } member: { OneOf { - Color.modifierParser(in: context).map(AnyColorModifier.colorModifier) + Color.Resolvable.modifierParser(in: context).map(AnyColorModifier.colorModifier) members.map(AnyColorModifier.member) } } - .map { base, modifiers -> (base: SwiftUI.Color, members: [Members.Output]) in + .map { base, modifiers -> (base: Color.Resolvable, members: [Members.Output]) in let colorModifiers: [ColorModifier] = modifiers .compactMap({ guard case let .colorModifier(modifier) = $0 else { return nil } @@ -193,10 +320,7 @@ struct _ColorParser: Parser where Members.Input == Substring.UT guard case let .member(member) = $0 else { return nil } return member }) - let color = colorModifiers.reduce(into: base) { - $0 = $1.apply(to: $0) - } - return (base: color, members: members) + return (base: .init(storage: base.storage, modifiers: base.modifiers + colorModifiers), members: members) } } } @@ -214,17 +338,17 @@ enum ColorModifier { struct Opacity { static let name = "opacity" - let value: Double + let value: AttributeReference - init(_ value: Double) { + init(_ value: AttributeReference) { self.value = value } } - func apply(to color: SwiftUI.Color) -> SwiftUI.Color { + func apply(to color: SwiftUI.Color, on element: ElementNode) -> SwiftUI.Color { switch self { case let .opacity(opacity): - return color.opacity(opacity.value) + return color.opacity(opacity.value.resolve(on: element)) } } } diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Gradient+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Gradient+ParseableModifierValue.swift index d21f9e0a7..de6368c2a 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Gradient+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/Gradient+ParseableModifierValue.swift @@ -17,18 +17,26 @@ import LiveViewNativeStylesheet /// Color("MyColor").gradient /// ``` @_documentation(visibility: public) -extension AnyGradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _ThrowingParse { (base: SwiftUI.Color, members: [()]) in - guard !members.isEmpty else { - throw ModifierParseError(error: .missingRequiredArgument("gradient"), metadata: context.metadata) - } - return base.gradient - } with: { - _ColorParser(context: context) { - ConstantAtomLiteral("gradient") +extension AnyGradient { + struct Resolvable: ParseableModifierValue { + let color: Color.Resolvable + + public static func parser(in context: ParseableModifierContext) -> some Parser { + _ThrowingParse { (base: Color.Resolvable, members: [()]) in + guard !members.isEmpty else { + throw ModifierParseError(error: .missingRequiredArgument("gradient"), metadata: context.metadata) + } + return .init(color: base) + } with: { + _ColorParser(context: context) { + ConstantAtomLiteral("gradient") + } } } + + func resolve(on element: ElementNode) -> AnyGradient { + color.resolve(on: element).gradient + } } } @@ -41,23 +49,37 @@ extension AnyGradient: ParseableModifierValue { /// Gradient(stops: [Gradient.Stop(color: .red, location: 0), Gradient.Stop(color: .blue, location: 1)]) /// ``` @_documentation(visibility: public) -extension Gradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _Gradient.parser(in: context).map(\.value) - } - - @ParseableExpression - struct _Gradient { - static let name = "Gradient" - - let value: Gradient - - init(colors: [Color]) { - self.value = .init(colors: colors) +extension Gradient { + enum Resolvable: ParseableModifierValue { + case colors([Color.Resolvable]) + case stops([Gradient.Stop.Resolvable]) + + public static func parser(in context: ParseableModifierContext) -> some Parser { + _Gradient.parser(in: context).map(\.value) + } + + @ParseableExpression + struct _Gradient { + static let name = "Gradient" + + let value: Resolvable + + init(colors: [Color.Resolvable]) { + self.value = .colors(colors) + } + + init(stops: [Gradient.Stop.Resolvable]) { + self.value = .stops(stops) + } } - init(stops: [Gradient.Stop]) { - self.value = .init(stops: stops) + func resolve(on element: ElementNode) -> Gradient { + switch self { + case .colors(let colors): + Gradient(colors: colors.map({ $0.resolve(on: element) })) + case .stops(let stops): + Gradient(stops: stops.map({ $0.resolve(on: element) })) + } } } } @@ -71,136 +93,35 @@ extension Gradient: ParseableModifierValue { /// Gradient.Stop(color: .blue, location: 1) /// ``` @_documentation(visibility: public) -extension Gradient.Stop: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - MemberExpression { - ConstantAtomLiteral("Gradient") - } member: { - _Stop.parser(in: context).map(\.value) - } - .map({ _, member in - member - }) - } - - @ParseableExpression - struct _Stop { - static let name = "Stop" - - let value: Gradient.Stop - - init(color: Color, location: CGFloat) { - self.value = .init(color: color, location: location) - } - } -} - -extension AngularGradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _AngularGradient.parser(in: context).map(\.value) - } - - @ParseableExpression - struct _AngularGradient { - static let name = "AngularGradient" - - let value: AngularGradient - - public init(gradient: Gradient, center: UnitPoint, startAngle: Angle = .zero, endAngle: Angle = .zero) { - self.value = .init(gradient: gradient, center: center, startAngle: startAngle, endAngle: endAngle) - } - - public init(colors: [Color], center: UnitPoint, startAngle: Angle, endAngle: Angle) { - self.value = .init(colors: colors, center: center, startAngle: startAngle, endAngle: endAngle) - } - - public init(stops: [Gradient.Stop], center: UnitPoint, startAngle: Angle, endAngle: Angle) { - self.value = .init(stops: stops, center: center, startAngle: startAngle, endAngle: endAngle) - } - - public init(gradient: Gradient, center: UnitPoint, angle: Angle = .zero) { - self.value = .init(gradient: gradient, center: center, angle: angle) - } - - public init(colors: [Color], center: UnitPoint, angle: Angle = .zero) { - self.value = .init(colors: colors, center: center, angle: angle) - } - - public init(stops: [Gradient.Stop], center: UnitPoint, angle: Angle = .zero) { - self.value = .init(stops: stops, center: center, angle: angle) - } - } -} - -extension EllipticalGradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _EllipticalGradient.parser(in: context).map(\.value) - } - - @ParseableExpression - struct _EllipticalGradient { - static let name = "EllipticalGradient" - - let value: EllipticalGradient - - init(gradient: Gradient, center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = .init(gradient: gradient, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) - } - - init(colors: [Color], center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = .init(colors: colors, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) - } - - init(stops: [Gradient.Stop], center: UnitPoint = .center, startRadiusFraction: CGFloat = 0, endRadiusFraction: CGFloat = 0.5) { - self.value = .init(stops: stops, center: center, startRadiusFraction: startRadiusFraction, endRadiusFraction: endRadiusFraction) - } - } -} - -extension LinearGradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _LinearGradient.parser(in: context).map(\.value) - } - - @ParseableExpression - struct _LinearGradient { - static let name = "LinearGradient" - - let value: LinearGradient - - init(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = .init(gradient: gradient, startPoint: startPoint, endPoint: endPoint) - } - init(colors: [Color], startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = .init(colors: colors, startPoint: startPoint, endPoint: endPoint) - } - init(stops: [Gradient.Stop], startPoint: UnitPoint, endPoint: UnitPoint) { - self.value = .init(stops: stops, startPoint: startPoint, endPoint: endPoint) - } - } -} - -extension RadialGradient: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { - _RadialGradient.parser(in: context).map(\.value) - } - - @ParseableExpression - struct _RadialGradient { - static let name = "RadialGradient" - - let value: RadialGradient - - init(gradient: Gradient, center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = .init(gradient: gradient, center: center, startRadius: startRadius, endRadius: endRadius) - } - - init(colors: [Color], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = .init(colors: colors, center: center, startRadius: startRadius, endRadius: endRadius) +extension Gradient.Stop { + struct Resolvable: ParseableModifierValue { + let color: Color.Resolvable + let location: AttributeReference + + public static func parser(in context: ParseableModifierContext) -> some Parser { + MemberExpression { + ConstantAtomLiteral("Gradient") + } member: { + _Stop.parser(in: context).map(\.value) + } + .map({ _, member in + member + }) + } + + @ParseableExpression + struct _Stop { + static let name = "Stop" + + let value: Gradient.Stop.Resolvable + + init(color: Color.Resolvable, location: AttributeReference) { + self.value = .init(color: color, location: location) + } } - init(stops: [Gradient.Stop], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { - self.value = .init(stops: stops, center: center, startRadius: startRadius, endRadius: endRadius) + func resolve(on element: ElementNode) -> Gradient.Stop { + .init(color: color.resolve(on: element), location: location.resolve(on: element)) } } } diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/ShadowStyle+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/ShadowStyle+ParseableModifierValue.swift index ceac5844c..aa9d6d046 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/ShadowStyle+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/ShapeStyles/ShadowStyle+ParseableModifierValue.swift @@ -38,24 +38,57 @@ import LiveViewNativeStylesheet /// .inner(color: .red, radius: 8, x: 10, y: -8) /// ``` @_documentation(visibility: public) -extension ShadowStyle: ParseableModifierValue { +enum _ShadowStyle: ParseableModifierValue { + case drop(_drop) + case inner(_inner) + public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { OneOf { - _drop.parser(in: context).map(\.value) - _inner.parser(in: context).map(\.value) + _drop.parser(in: context).map(Self.drop) + _inner.parser(in: context).map(Self.inner) } } } + func resolve(on element: ElementNode) -> ShadowStyle { + switch self { + case .drop(let drop): + .drop( + color: drop.color.resolve(on: element), + radius: drop.radius.resolve(on: element), + x: drop.x.resolve(on: element), + y: drop.y.resolve(on: element) + ) + case .inner(let inner): + .inner( + color: inner.color.resolve(on: element), + radius: inner.radius.resolve(on: element), + x: inner.x.resolve(on: element), + y: inner.y.resolve(on: element) + ) + } + } + @ParseableExpression struct _drop { static let name = "drop" - let value: ShadowStyle + let color: Color.Resolvable + let radius: AttributeReference + let x: AttributeReference + let y: AttributeReference - init(color: Color = .init(.sRGBLinear, white: 0, opacity: 0.33), radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) { - self.value = .drop(color: color, radius: radius, x: x, y: y) + init( + color: Color.Resolvable = .init(.init(.sRGBLinear, white: 0, opacity: 0.33)), + radius: AttributeReference, + x: AttributeReference = .init(0), + y: AttributeReference = .init(0) + ) { + self.color = color + self.radius = radius + self.x = x + self.y = y } } @@ -63,10 +96,21 @@ extension ShadowStyle: ParseableModifierValue { struct _inner { static let name = "inner" - let value: ShadowStyle + let color: Color.Resolvable + let radius: AttributeReference + let x: AttributeReference + let y: AttributeReference - init(color: Color = .init(.sRGBLinear, white: 0, opacity: 0.55), radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) { - self.value = .inner(color: color, radius: radius, x: x, y: y) + init( + color: Color.Resolvable = .init(.init(.sRGBLinear, white: 0, opacity: 0.55)), + radius: AttributeReference, + x: AttributeReference = .init(0), + y: AttributeReference = .init(0) + ) { + self.color = color + self.radius = radius + self.x = x + self.y = y } } } diff --git a/Sources/LiveViewNative/Stylesheets/ParseableTypes/Text+ParseableModifierValue.swift b/Sources/LiveViewNative/Stylesheets/ParseableTypes/Text+ParseableModifierValue.swift index 8c4bbdc48..838b4db03 100644 --- a/Sources/LiveViewNative/Stylesheets/ParseableTypes/Text+ParseableModifierValue.swift +++ b/Sources/LiveViewNative/Stylesheets/ParseableTypes/Text+ParseableModifierValue.swift @@ -86,11 +86,26 @@ extension SwiftUI.Text.Scale: ParseableModifierValue { /// LineStyle(pattern: .solid, color: .blue) /// ``` @_documentation(visibility: public) -extension SwiftUI.Text.LineStyle: ParseableModifierValue { - public static func parser(in context: ParseableModifierContext) -> some Parser { +enum _AnyTextLineStyle: ParseableModifierValue { + case single + case value(_TextLineStyle) + + static func parser(in context: ParseableModifierContext) -> some Parser { OneOf { ImplicitStaticMember(["single": .single]) - _TextLineStyle.parser(in: context).map(\.value) + _TextLineStyle.parser(in: context).map(Self.value) + } + } + + func resolve(on element: ElementNode, in context: LiveContext) -> SwiftUI.Text.LineStyle { + switch self { + case .single: + .single + case .value(let style): + .init( + pattern: style.pattern, + color: style.color?.resolve(on: element, in: context) + ) } } } @@ -98,10 +113,12 @@ extension SwiftUI.Text.LineStyle: ParseableModifierValue { @ParseableExpression struct _TextLineStyle { static let name = "LineStyle" - let value: SwiftUI.Text.LineStyle + let pattern: SwiftUI.Text.LineStyle.Pattern + let color: Color.Resolvable? - init(pattern: SwiftUI.Text.LineStyle.Pattern = .solid, color: Color? = nil) { - self.value = .init(pattern: pattern, color: color) + init(pattern: SwiftUI.Text.LineStyle.Pattern = .solid, color: Color.Resolvable? = nil) { + self.pattern = pattern + self.color = color } } diff --git a/Sources/LiveViewNative/Utils/DOM.swift b/Sources/LiveViewNative/Utils/DOM.swift index a94c9e153..f7df38639 100644 --- a/Sources/LiveViewNative/Utils/DOM.swift +++ b/Sources/LiveViewNative/Utils/DOM.swift @@ -23,15 +23,17 @@ import LiveViewNativeCore /// - ``depthFirstChildren()`` /// - ``elementChildren()`` /// - ``innerText()`` -public struct ElementNode { - let node: Node - let data: ElementData +public struct ElementNode: Identifiable { + public let node: Node + public let data: ElementData init(node: Node, data: ElementData) { self.node = node self.data = data } + public var id: NodeRef { node.id } + /// A sequence representing this element's direct children. public func children() -> NodeChildrenSequence { node.children() } /// A sequence that traverses the nested child nodes of this element in depth-first order. @@ -80,9 +82,9 @@ public struct ElementNode { /// > The strings `"true"`/`"false"` are ignored, and only the presence of the attribute is considered. /// > A value of `"false"` would still return `true`. public func attributeBoolean(for name: AttributeName) -> Bool { - guard let attribute = attribute(named: name)?.value + guard let attribute = attribute(named: name) else { return false } - return attribute != "false" + return attribute.value != "false" } /// The text of this element. @@ -112,7 +114,7 @@ public struct ElementNode { } extension Node { - func asElement() -> ElementNode? { + public func asElement() -> ElementNode? { if case .element(let data) = self.data { return ElementNode(node: self, data: data) } else { diff --git a/Sources/LiveViewNative/_GeneratedModifiers.swift b/Sources/LiveViewNative/_GeneratedModifiers.swift index 64a60d7f8..18ad732df 100644 --- a/Sources/LiveViewNative/_GeneratedModifiers.swift +++ b/Sources/LiveViewNative/_GeneratedModifiers.swift @@ -694,19 +694,19 @@ struct _backgroundModifier: ViewModifier { indirect case _1(edges: SwiftUI.Edge.Set = .all ) - indirect case _2(style: AnyShapeStyle,edges: SwiftUI.Edge.Set = .all ) + indirect case _2(style: AnyShapeStyle.Resolvable,edges: SwiftUI.Edge.Set = .all ) indirect case _3(shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) - indirect case _4(style: AnyShapeStyle,shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) + indirect case _4(style: AnyShapeStyle.Resolvable,shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) indirect case _5(shape: AnyInsettableShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) - indirect case _6(style: AnyShapeStyle,shape: AnyInsettableShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) + indirect case _6(style: AnyShapeStyle.Resolvable,shape: AnyInsettableShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) } @@ -746,7 +746,7 @@ struct _backgroundModifier: ViewModifier { - init(_ style: AnyShapeStyle,ignoresSafeAreaEdges edges: SwiftUI.Edge.Set = .all ) { + init(_ style: AnyShapeStyle.Resolvable,ignoresSafeAreaEdges edges: SwiftUI.Edge.Set = .all ) { self.value = ._2(style: style, edges: edges) } @@ -760,7 +760,7 @@ struct _backgroundModifier: ViewModifier { - init(_ style: AnyShapeStyle,in shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { + init(_ style: AnyShapeStyle.Resolvable,in shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { self.value = ._4(style: style, shape: shape, fillStyle: fillStyle) } @@ -774,7 +774,7 @@ struct _backgroundModifier: ViewModifier { - init(_ style: AnyShapeStyle,in shape: AnyInsettableShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { + init(_ style: AnyShapeStyle.Resolvable,in shape: AnyInsettableShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { self.value = ._6(style: style, shape: shape, fillStyle: fillStyle) } @@ -805,7 +805,7 @@ struct _backgroundModifier: ViewModifier { __content - .background(style, ignoresSafeAreaEdges: edges) + .background(style.resolve(on: element, in: context), ignoresSafeAreaEdges: edges) @@ -821,7 +821,7 @@ struct _backgroundModifier: ViewModifier { __content - .background(style, in: shape, fillStyle: fillStyle) + .background(style.resolve(on: element, in: context), in: shape, fillStyle: fillStyle) @@ -837,7 +837,7 @@ struct _backgroundModifier: ViewModifier { __content - .background(style, in: shape, fillStyle: fillStyle) + .background(style.resolve(on: element, in: context), in: shape, fillStyle: fillStyle) } @@ -851,21 +851,21 @@ struct _backgroundStyleModifier: ViewModifier { enum Value { case _never - indirect case _0(style: AnyShapeStyle) + indirect case _0(style: AnyShapeStyle.Resolvable) } let value: Value - - + @ObservedElement private var element + @LiveContext private var context - init(_ style: AnyShapeStyle) { + init(_ style: AnyShapeStyle.Resolvable) { self.value = ._0(style: style) } @@ -880,7 +880,7 @@ struct _backgroundStyleModifier: ViewModifier { __content - .backgroundStyle(style) + .backgroundStyle(style.resolve(on: element, in: context)) } @@ -922,28 +922,28 @@ struct _badgeModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(iOS 15.0,visionOS 1.0,macOS 12.0, *) init(_ count: AttributeReference) { self.value = ._0(count: count) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(iOS 15.0,visionOS 1.0,macOS 12.0, *) init(_ label: TextReference?) { self.value = ._1(label: label) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(macOS 12.0,iOS 15.0,visionOS 1.0, *) init(_ key: SwiftUI.LocalizedStringKey?) { self.value = ._2(key: key) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(macOS 12.0,iOS 15.0,visionOS 1.0, *) init(_ label: AttributeReference) { self.value = ._3(label: label) @@ -956,7 +956,7 @@ struct _badgeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(count): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(iOS 15.0,visionOS 1.0,macOS 12.0, *) { let count = count as! AttributeReference __content .badge(count.resolve(on: element, in: context)) @@ -964,7 +964,7 @@ struct _badgeModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._1(label): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(iOS 15.0,visionOS 1.0,macOS 12.0, *) { let label = label as? TextReference __content .badge(label?.resolve(on: element, in: context)) @@ -972,7 +972,7 @@ struct _badgeModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._2(key): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(macOS 12.0,iOS 15.0,visionOS 1.0, *) { let key = key as? SwiftUI.LocalizedStringKey __content .badge(key) @@ -980,7 +980,7 @@ struct _badgeModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._3(label): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(macOS 12.0,iOS 15.0,visionOS 1.0, *) { let label = label as! AttributeReference __content .badge(label.resolve(on: element, in: context)) @@ -1083,7 +1083,7 @@ struct _borderModifier: ViewModifier { enum Value { case _never - indirect case _0(content: AnyShapeStyle,width: AttributeReference = .init(storage: .constant(1)) ) + indirect case _0(content: AnyShapeStyle.Resolvable,width: AttributeReference = .init(storage: .constant(1)) ) } @@ -1097,7 +1097,7 @@ struct _borderModifier: ViewModifier { - init(_ content: AnyShapeStyle,width: AttributeReference = .init(storage: .constant(1)) ) { + init(_ content: AnyShapeStyle.Resolvable,width: AttributeReference = .init(storage: .constant(1)) ) { self.value = ._0(content: content, width: width) } @@ -1112,7 +1112,7 @@ struct _borderModifier: ViewModifier { __content - .border(content, width: width.resolve(on: element, in: context)) + .border(content.resolve(on: element, in: context), width: width.resolve(on: element, in: context)) } @@ -1404,7 +1404,7 @@ struct _colorMultiplyModifier: ViewModifier { enum Value { case _never - indirect case _0(color: AttributeReference) + indirect case _0(color: Color.Resolvable) } @@ -1418,7 +1418,7 @@ struct _colorMultiplyModifier: ViewModifier { - init(_ color: AttributeReference) { + init(_ color: Color.Resolvable) { self.value = ._0(color: color) } @@ -1651,7 +1651,7 @@ struct _containerRelativeFrameModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) + @available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) init(_ axes: SwiftUI.Axis.Set,alignment: AttributeReference = .init(storage: .constant(.center)) ) { self.value = ._0(axes: axes, alignment: alignment) @@ -1671,7 +1671,7 @@ struct _containerRelativeFrameModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(axes, alignment): - if #available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) { + if #available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) { let axes = axes as! SwiftUI.Axis.Set let alignment = alignment as! AttributeReference __content @@ -1875,7 +1875,7 @@ struct _contextMenuModifier: ViewModifier { } #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - @available(iOS 16.0,tvOS 16.0,visionOS 1.0,macOS 13.0, *) + @available(tvOS 16.0,visionOS 1.0,macOS 13.0,iOS 16.0, *) init(menuItems: ViewReference=ViewReference(value: []),preview: ViewReference=ViewReference(value: [])) { self.value = ._1(menuItems: menuItems, preview: preview) @@ -1896,7 +1896,7 @@ struct _contextMenuModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) case let ._1(menuItems, preview): - if #available(iOS 16.0,tvOS 16.0,visionOS 1.0,macOS 13.0, *) { + if #available(tvOS 16.0,visionOS 1.0,macOS 13.0,iOS 16.0, *) { let menuItems = menuItems as! ViewReference let preview = preview as! ViewReference __content @@ -1970,7 +1970,7 @@ struct _controlGroupStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - @available(iOS 15.0,macOS 12.0,tvOS 17.0,visionOS 1.0, *) + @available(visionOS 1.0,macOS 12.0,tvOS 17.0,iOS 15.0, *) init(_ style: AnyControlGroupStyle) { self.value = ._0(style: style) @@ -1983,7 +1983,7 @@ struct _controlGroupStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) case let ._0(style): - if #available(iOS 15.0,macOS 12.0,tvOS 17.0,visionOS 1.0, *) { + if #available(visionOS 1.0,macOS 12.0,tvOS 17.0,iOS 15.0, *) { let style = style as! AnyControlGroupStyle __content .controlGroupStyle(style) @@ -2013,7 +2013,7 @@ struct _controlSizeModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(iOS 15.0,macOS 10.15,visionOS 1.0,watchOS 9.0, *) + @available(watchOS 9.0,visionOS 1.0,macOS 10.15,iOS 15.0, *) init(_ controlSize: SwiftUI.ControlSize) { self.value = ._0(controlSize: controlSize) @@ -2026,7 +2026,7 @@ struct _controlSizeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(controlSize): - if #available(iOS 15.0,macOS 10.15,visionOS 1.0,watchOS 9.0, *) { + if #available(watchOS 9.0,visionOS 1.0,macOS 10.15,iOS 15.0, *) { let controlSize = controlSize as! SwiftUI.ControlSize __content .controlSize(controlSize) @@ -2056,7 +2056,7 @@ struct _coordinateSpaceModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(visionOS 1.0,tvOS 17.0,macOS 14.0,watchOS 10.0,iOS 17.0, *) + @available(macOS 14.0,visionOS 1.0,tvOS 17.0,iOS 17.0,watchOS 10.0, *) init(_ name: SwiftUI.NamedCoordinateSpace) { self.value = ._0(name: name) @@ -2069,7 +2069,7 @@ struct _coordinateSpaceModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(name): - if #available(visionOS 1.0,tvOS 17.0,macOS 14.0,watchOS 10.0,iOS 17.0, *) { + if #available(macOS 14.0,visionOS 1.0,tvOS 17.0,iOS 17.0,watchOS 10.0, *) { let name = name as! SwiftUI.NamedCoordinateSpace __content .coordinateSpace(name) @@ -2099,7 +2099,7 @@ struct _datePickerStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(iOS 13.0,macOS 10.15,watchOS 10.0,visionOS 1.0, *) + @available(iOS 13.0,visionOS 1.0,watchOS 10.0,macOS 10.15, *) init(_ style: AnyDatePickerStyle) { self.value = ._0(style: style) @@ -2112,7 +2112,7 @@ struct _datePickerStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(style): - if #available(iOS 13.0,macOS 10.15,watchOS 10.0,visionOS 1.0, *) { + if #available(iOS 13.0,visionOS 1.0,watchOS 10.0,macOS 10.15, *) { let style = style as! AnyDatePickerStyle __content .datePickerStyle(style) @@ -2142,7 +2142,7 @@ struct _defaultScrollAnchorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) + @available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ anchor: AttributeReference?) { self.value = ._0(anchor: anchor) @@ -2155,7 +2155,7 @@ struct _defaultScrollAnchorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(anchor): - if #available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) { + if #available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) { let anchor = anchor as? AttributeReference __content .defaultScrollAnchor(anchor?.resolve(on: element, in: context)) @@ -2329,28 +2329,28 @@ struct _dialogSuppressionToggleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ titleKey: SwiftUI.LocalizedStringKey,isSuppressed: ChangeTracked) { self.value = ._0(titleKey: titleKey) self.__0_isSuppressed = isSuppressed } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(watchOS 10.0,macOS 14.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) init(_ title: AttributeReference,isSuppressed: ChangeTracked) { self.value = ._1(title: title) self.__1_isSuppressed = isSuppressed } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(_ label: TextReference,isSuppressed: ChangeTracked) { self.value = ._2(label: label) self.__2_isSuppressed = isSuppressed } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(isSuppressed: ChangeTracked) { self.value = ._3 self.__3_isSuppressed = isSuppressed @@ -2363,7 +2363,7 @@ struct _dialogSuppressionToggleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(titleKey): - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) { let titleKey = titleKey as! SwiftUI.LocalizedStringKey __content .dialogSuppressionToggle(titleKey, isSuppressed: __0_isSuppressed.projectedValue) @@ -2371,7 +2371,7 @@ struct _dialogSuppressionToggleModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(title): - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(watchOS 10.0,macOS 14.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) { let title = title as! AttributeReference __content .dialogSuppressionToggle(title.resolve(on: element, in: context), isSuppressed: __1_isSuppressed.projectedValue) @@ -2379,7 +2379,7 @@ struct _dialogSuppressionToggleModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._2(label): - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let label = label as! TextReference __content .dialogSuppressionToggle(label.resolve(on: element, in: context), isSuppressed: __2_isSuppressed.projectedValue) @@ -2387,7 +2387,7 @@ struct _dialogSuppressionToggleModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case ._3: - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { __content .dialogSuppressionToggle(isSuppressed: __3_isSuppressed.projectedValue) @@ -2629,7 +2629,7 @@ struct _fileDialogCustomizationIDModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0,visionOS 1.0, *) init(_ id: AttributeReference) { self.value = ._0(id: id) @@ -2642,7 +2642,7 @@ struct _fileDialogCustomizationIDModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(id): - if #available(visionOS 1.0,iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0,visionOS 1.0, *) { let id = id as! AttributeReference __content .fileDialogCustomizationID(id.resolve(on: element, in: context)) @@ -2672,7 +2672,7 @@ struct _fileDialogImportsUnresolvedAliasesModifier: ViewModifie #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,visionOS 1.0,iOS 17.0, *) init(_ imports: AttributeReference) { self.value = ._0(imports: imports) @@ -2685,7 +2685,7 @@ struct _fileDialogImportsUnresolvedAliasesModifier: ViewModifie fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(imports): - if #available(visionOS 1.0,iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,visionOS 1.0,iOS 17.0, *) { let imports = imports as! AttributeReference __content .fileDialogImportsUnresolvedAliases(imports.resolve(on: element, in: context)) @@ -2715,7 +2715,7 @@ struct _findDisabledModifier: ViewModifier { #if os(iOS) || os(visionOS) - @available(visionOS 1.0,iOS 16.0, *) + @available(iOS 16.0,visionOS 1.0, *) init(_ isDisabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isDisabled: isDisabled) @@ -2728,7 +2728,7 @@ struct _findDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) case let ._0(isDisabled): - if #available(visionOS 1.0,iOS 16.0, *) { + if #available(iOS 16.0,visionOS 1.0, *) { let isDisabled = isDisabled as! AttributeReference __content .findDisabled(isDisabled.resolve(on: element, in: context)) @@ -2758,7 +2758,7 @@ struct _findNavigatorModifier: ViewModifier { #if os(iOS) || os(visionOS) - @available(visionOS 1.0,iOS 16.0, *) + @available(iOS 16.0,visionOS 1.0, *) init(isPresented: ChangeTracked) { self.value = ._0 self.__0_isPresented = isPresented @@ -2771,7 +2771,7 @@ struct _findNavigatorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) case ._0: - if #available(visionOS 1.0,iOS 16.0, *) { + if #available(iOS 16.0,visionOS 1.0, *) { __content .findNavigator(isPresented: __0_isPresented.projectedValue) @@ -2907,7 +2907,7 @@ struct _focusEffectDisabledModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) + @available(iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) init(_ disabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(disabled: disabled) @@ -2920,7 +2920,7 @@ struct _focusEffectDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(disabled): - if #available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) { + if #available(iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) { let disabled = disabled as! AttributeReference __content .focusEffectDisabled(disabled.resolve(on: element, in: context)) @@ -2950,7 +2950,7 @@ struct _focusSectionModifier: ViewModifier { #if os(macOS) || os(tvOS) - @available(tvOS 15.0,macOS 13.0, *) + @available(macOS 13.0,tvOS 15.0, *) init() { self.value = ._0 @@ -2963,7 +2963,7 @@ struct _focusSectionModifier: ViewModifier { fatalError("unreachable") #if os(macOS) || os(tvOS) case ._0: - if #available(tvOS 15.0,macOS 13.0, *) { + if #available(macOS 13.0,tvOS 15.0, *) { __content .focusSection() @@ -2998,14 +2998,14 @@ struct _focusableModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,macOS 12.0,tvOS 15.0,watchOS 8.0,visionOS 1.0, *) + @available(watchOS 8.0,visionOS 1.0,macOS 12.0,tvOS 15.0,iOS 17.0, *) init(_ isFocusable: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isFocusable: isFocusable) } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) + @available(iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) init(_ isFocusable: AttributeReference = .init(storage: .constant(true)), interactions: SwiftUI.FocusInteractions) { self.value = ._1(isFocusable: isFocusable, interactions: interactions) @@ -3018,7 +3018,7 @@ struct _focusableModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(isFocusable): - if #available(iOS 17.0,macOS 12.0,tvOS 15.0,watchOS 8.0,visionOS 1.0, *) { + if #available(watchOS 8.0,visionOS 1.0,macOS 12.0,tvOS 15.0,iOS 17.0, *) { let isFocusable = isFocusable as! AttributeReference __content .focusable(isFocusable.resolve(on: element, in: context)) @@ -3026,7 +3026,7 @@ struct _focusableModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(isFocusable, interactions): - if #available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) { + if #available(iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) { let isFocusable = isFocusable as! AttributeReference let interactions = interactions as! SwiftUI.FocusInteractions __content @@ -3227,7 +3227,7 @@ struct _fullScreenCoverModifier: ViewModifier { @Event private var _0_onDismiss__0: Event.EventHandler #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 14.0,watchOS 7.0,tvOS 14.0,visionOS 1.0, *) + @available(tvOS 14.0,iOS 14.0,watchOS 7.0,visionOS 1.0, *) init(isPresented: ChangeTracked,onDismiss onDismiss__0: Event=Event(), content: ViewReference=ViewReference(value: [])) { self.value = ._0(content: content) self.__0_isPresented = isPresented @@ -3241,7 +3241,7 @@ self.__0_onDismiss__0 = onDismiss__0 fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(content): - if #available(iOS 14.0,watchOS 7.0,tvOS 14.0,visionOS 1.0, *) { + if #available(tvOS 14.0,iOS 14.0,watchOS 7.0,visionOS 1.0, *) { let content = content as! ViewReference __content .fullScreenCover(isPresented: __0_isPresented.projectedValue, onDismiss: { __0_onDismiss__0.wrappedValue() }, content: { content.resolve(on: element, in: context) }) @@ -3271,7 +3271,7 @@ struct _gaugeStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(iOS 16.0,macOS 13.0,visionOS 1.0,watchOS 7.0, *) + @available(watchOS 7.0,macOS 13.0,visionOS 1.0,iOS 16.0, *) init(_ style: AnyGaugeStyle) { self.value = ._0(style: style) @@ -3284,7 +3284,7 @@ struct _gaugeStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(style): - if #available(iOS 16.0,macOS 13.0,visionOS 1.0,watchOS 7.0, *) { + if #available(watchOS 7.0,macOS 13.0,visionOS 1.0,iOS 16.0, *) { let style = style as! AnyGaugeStyle __content .gaugeStyle(style) @@ -3314,7 +3314,7 @@ struct _geometryGroupModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) + @available(tvOS 17.0,iOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init() { self.value = ._0 @@ -3327,7 +3327,7 @@ struct _geometryGroupModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case ._0: - if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { + if #available(tvOS 17.0,iOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { __content .geometryGroup() @@ -3679,7 +3679,7 @@ struct _groupBoxStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 11.0,visionOS 1.0,iOS 14.0, *) + @available(iOS 14.0,visionOS 1.0,macOS 11.0, *) init(_ style: AnyGroupBoxStyle) { self.value = ._0(style: style) @@ -3692,7 +3692,7 @@ struct _groupBoxStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(style): - if #available(macOS 11.0,visionOS 1.0,iOS 14.0, *) { + if #available(iOS 14.0,visionOS 1.0,macOS 11.0, *) { let style = style as! AnyGroupBoxStyle __content .groupBoxStyle(style) @@ -3982,14 +3982,14 @@ struct _hoverEffectModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) - @available(visionOS 1.0,iOS 13.4,tvOS 16.0, *) + @available(iOS 13.4,visionOS 1.0,tvOS 16.0, *) init(_ effect: SwiftUI.HoverEffect = .automatic ) { self.value = ._0(effect: effect) } #endif #if os(iOS) || os(tvOS) || os(visionOS) - @available(visionOS 1.0,iOS 17.0,tvOS 17.0, *) + @available(tvOS 17.0,iOS 17.0,visionOS 1.0, *) init(_ effect: SwiftUI.HoverEffect = .automatic, isEnabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._1(effect: effect, isEnabled: isEnabled) @@ -4002,7 +4002,7 @@ struct _hoverEffectModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) case let ._0(effect): - if #available(visionOS 1.0,iOS 13.4,tvOS 16.0, *) { + if #available(iOS 13.4,visionOS 1.0,tvOS 16.0, *) { let effect = effect as! SwiftUI.HoverEffect __content .hoverEffect(effect) @@ -4010,7 +4010,7 @@ struct _hoverEffectModifier: ViewModifier { #endif #if os(iOS) || os(tvOS) || os(visionOS) case let ._1(effect, isEnabled): - if #available(visionOS 1.0,iOS 17.0,tvOS 17.0, *) { + if #available(tvOS 17.0,iOS 17.0,visionOS 1.0, *) { let effect = effect as! SwiftUI.HoverEffect let isEnabled = isEnabled as! AttributeReference __content @@ -4041,7 +4041,7 @@ struct _hoverEffectDisabledModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) - @available(visionOS 1.0,iOS 17.0,tvOS 17.0, *) + @available(tvOS 17.0,iOS 17.0,visionOS 1.0, *) init(_ disabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(disabled: disabled) @@ -4054,7 +4054,7 @@ struct _hoverEffectDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) case let ._0(disabled): - if #available(visionOS 1.0,iOS 17.0,tvOS 17.0, *) { + if #available(tvOS 17.0,iOS 17.0,visionOS 1.0, *) { let disabled = disabled as! AttributeReference __content .hoverEffectDisabled(disabled.resolve(on: element, in: context)) @@ -4213,7 +4213,7 @@ struct _indexViewStyleModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 8.0,visionOS 1.0,iOS 14.0,tvOS 14.0, *) + @available(tvOS 14.0,visionOS 1.0,iOS 14.0,watchOS 8.0, *) init(_ style: AnyIndexViewStyle) { self.value = ._0(style: style) @@ -4226,7 +4226,7 @@ struct _indexViewStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(style): - if #available(watchOS 8.0,visionOS 1.0,iOS 14.0,tvOS 14.0, *) { + if #available(tvOS 14.0,visionOS 1.0,iOS 14.0,watchOS 8.0, *) { let style = style as! AnyIndexViewStyle __content .indexViewStyle(style) @@ -4256,7 +4256,7 @@ struct _inspectorModifier: ViewModifier { #if os(iOS) || os(macOS) - @available(iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0, *) init(isPresented: ChangeTracked,content: ViewReference=ViewReference(value: [])) { self.value = ._0(content: content) self.__0_isPresented = isPresented @@ -4269,7 +4269,7 @@ struct _inspectorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) case let ._0(content): - if #available(iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0, *) { let content = content as! ViewReference __content .inspector(isPresented: __0_isPresented.projectedValue, content: { content.resolve(on: element, in: context) }) @@ -4304,14 +4304,14 @@ struct _inspectorColumnWidthModifier: ViewModifier { #if os(iOS) || os(macOS) - @available(iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0, *) init(min: AttributeReference? = .init(storage: .constant(nil)), ideal: AttributeReference,max: AttributeReference? = .init(storage: .constant(nil)) ) { self.value = ._0(min: min, ideal: ideal, max: max) } #endif #if os(iOS) || os(macOS) - @available(iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0, *) init(_ width: AttributeReference) { self.value = ._1(width: width) @@ -4324,7 +4324,7 @@ struct _inspectorColumnWidthModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) case let ._0(min, ideal, max): - if #available(iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0, *) { let min = min as? AttributeReference let ideal = ideal as! AttributeReference let max = max as? AttributeReference @@ -4334,7 +4334,7 @@ let max = max as? AttributeReference #endif #if os(iOS) || os(macOS) case let ._1(width): - if #available(iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0, *) { let width = width as! AttributeReference __content .inspectorColumnWidth(width.resolve(on: element, in: context)) @@ -4450,7 +4450,7 @@ struct _invalidatableContentModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) + @available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ invalidatable: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(invalidatable: invalidatable) @@ -4463,7 +4463,7 @@ struct _invalidatableContentModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(invalidatable): - if #available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) { + if #available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) { let invalidatable = invalidatable as! AttributeReference __content .invalidatableContent(invalidatable.resolve(on: element, in: context)) @@ -4508,28 +4508,28 @@ struct _keyboardShortcutModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 11.0,visionOS 1.0,iOS 14.0, *) + @available(iOS 14.0,visionOS 1.0,macOS 11.0, *) init(_ key: SwiftUI.KeyEquivalent,modifiers: SwiftUI.EventModifiers = .command ) { self.value = ._0(key: key, modifiers: modifiers) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 11.0,visionOS 1.0,iOS 14.0, *) + @available(iOS 14.0,visionOS 1.0,macOS 11.0, *) init(_ shortcut: SwiftUI.KeyboardShortcut) { self.value = ._1(shortcut: shortcut) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.3,visionOS 1.0,iOS 15.4, *) + @available(iOS 15.4,visionOS 1.0,macOS 12.3, *) init(_ shortcut: SwiftUI.KeyboardShortcut?) { self.value = ._2(shortcut: shortcut) } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(iOS 15.0,visionOS 1.0,macOS 12.0, *) init(_ key: SwiftUI.KeyEquivalent,modifiers: SwiftUI.EventModifiers = .command, localization: SwiftUI.KeyboardShortcut.Localization) { self.value = ._3(key: key, modifiers: modifiers, localization: localization) @@ -4542,7 +4542,7 @@ struct _keyboardShortcutModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(key, modifiers): - if #available(macOS 11.0,visionOS 1.0,iOS 14.0, *) { + if #available(iOS 14.0,visionOS 1.0,macOS 11.0, *) { let key = key as! SwiftUI.KeyEquivalent let modifiers = modifiers as! SwiftUI.EventModifiers __content @@ -4551,7 +4551,7 @@ let modifiers = modifiers as! SwiftUI.EventModifiers #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._1(shortcut): - if #available(macOS 11.0,visionOS 1.0,iOS 14.0, *) { + if #available(iOS 14.0,visionOS 1.0,macOS 11.0, *) { let shortcut = shortcut as! SwiftUI.KeyboardShortcut __content .keyboardShortcut(shortcut) @@ -4559,7 +4559,7 @@ let modifiers = modifiers as! SwiftUI.EventModifiers #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._2(shortcut): - if #available(macOS 12.3,visionOS 1.0,iOS 15.4, *) { + if #available(iOS 15.4,visionOS 1.0,macOS 12.3, *) { let shortcut = shortcut as? SwiftUI.KeyboardShortcut __content .keyboardShortcut(shortcut) @@ -4567,7 +4567,7 @@ let modifiers = modifiers as! SwiftUI.EventModifiers #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._3(key, modifiers, localization): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(iOS 15.0,visionOS 1.0,macOS 12.0, *) { let key = key as! SwiftUI.KeyEquivalent let modifiers = modifiers as! SwiftUI.EventModifiers let localization = localization as! SwiftUI.KeyboardShortcut.Localization @@ -4599,7 +4599,7 @@ struct _keyboardTypeModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) - @available(visionOS 1.0,iOS 13.0,tvOS 13.0, *) + @available(tvOS 13.0,visionOS 1.0,iOS 13.0, *) init(_ type: UIKit.UIKeyboardType) { self.value = ._0(type: type) @@ -4612,7 +4612,7 @@ struct _keyboardTypeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) case let ._0(type): - if #available(visionOS 1.0,iOS 13.0,tvOS 13.0, *) { + if #available(tvOS 13.0,visionOS 1.0,iOS 13.0, *) { let type = type as! UIKit.UIKeyboardType __content .keyboardType(type) @@ -4967,10 +4967,10 @@ struct _listItemTintModifier: ViewModifier { enum Value { case _never - indirect case _0(tint: SwiftUI.ListItemTint?) + indirect case _0(tint: ListItemTint.Resolvable?) - indirect case _1(tint: AttributeReference?) + indirect case _1(tint: Color.Resolvable?) } @@ -4986,14 +4986,14 @@ struct _listItemTintModifier: ViewModifier { - init(_ tint: SwiftUI.ListItemTint?) { + init(_ tint: ListItemTint.Resolvable?) { self.value = ._0(tint: tint) } - init(_ tint: AttributeReference?) { + init(_ tint: Color.Resolvable?) { self.value = ._1(tint: tint) } @@ -5008,7 +5008,7 @@ struct _listItemTintModifier: ViewModifier { __content - .listItemTint(tint) + .listItemTint(tint?.resolve(on: element, in: context)) @@ -5215,7 +5215,7 @@ struct _listRowSeparatorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 15.0,macOS 13.0, *) + @available(macOS 13.0,iOS 15.0,visionOS 1.0, *) init(_ visibility: AttributeReference,edges: SwiftUI.VerticalEdge.Set = .all ) { self.value = ._0(visibility: visibility, edges: edges) @@ -5228,7 +5228,7 @@ struct _listRowSeparatorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(visibility, edges): - if #available(visionOS 1.0,iOS 15.0,macOS 13.0, *) { + if #available(macOS 13.0,iOS 15.0,visionOS 1.0, *) { let visibility = visibility as! AttributeReference let edges = edges as! SwiftUI.VerticalEdge.Set __content @@ -5259,8 +5259,8 @@ struct _listRowSeparatorTintModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 15.0,macOS 13.0, *) - init(_ color: AttributeReference?,edges: SwiftUI.VerticalEdge.Set = .all ) { + @available(macOS 13.0,iOS 15.0,visionOS 1.0, *) + init(_ color: Color.Resolvable?,edges: SwiftUI.VerticalEdge.Set = .all ) { self.value = ._0(color: color, edges: edges) } @@ -5272,8 +5272,8 @@ struct _listRowSeparatorTintModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(color, edges): - if #available(visionOS 1.0,iOS 15.0,macOS 13.0, *) { - let color = color as? AttributeReference + if #available(macOS 13.0,iOS 15.0,visionOS 1.0, *) { + let color = color as? Color.Resolvable let edges = edges as! SwiftUI.VerticalEdge.Set __content .listRowSeparatorTint(color?.resolve(on: element, in: context), edges: edges) @@ -5303,7 +5303,7 @@ struct _listRowSpacingModifier: ViewModifier { #if os(iOS) || os(visionOS) - @available(iOS 15.0,visionOS 1.0, *) + @available(visionOS 1.0,iOS 15.0, *) init(_ spacing: AttributeReference?) { self.value = ._0(spacing: spacing) @@ -5316,7 +5316,7 @@ struct _listRowSpacingModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) case let ._0(spacing): - if #available(iOS 15.0,visionOS 1.0, *) { + if #available(visionOS 1.0,iOS 15.0, *) { let spacing = spacing as? AttributeReference __content .listRowSpacing(spacing?.resolve(on: element, in: context)) @@ -5346,7 +5346,7 @@ struct _listSectionSeparatorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 15.0,macOS 13.0, *) + @available(macOS 13.0,iOS 15.0,visionOS 1.0, *) init(_ visibility: AttributeReference,edges: SwiftUI.VerticalEdge.Set = .all ) { self.value = ._0(visibility: visibility, edges: edges) @@ -5359,7 +5359,7 @@ struct _listSectionSeparatorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(visibility, edges): - if #available(visionOS 1.0,iOS 15.0,macOS 13.0, *) { + if #available(macOS 13.0,iOS 15.0,visionOS 1.0, *) { let visibility = visibility as! AttributeReference let edges = edges as! SwiftUI.VerticalEdge.Set __content @@ -5390,8 +5390,8 @@ struct _listSectionSeparatorTintModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 15.0,macOS 13.0, *) - init(_ color: AttributeReference?,edges: SwiftUI.VerticalEdge.Set = .all ) { + @available(macOS 13.0,visionOS 1.0,iOS 15.0, *) + init(_ color: Color.Resolvable?,edges: SwiftUI.VerticalEdge.Set = .all ) { self.value = ._0(color: color, edges: edges) } @@ -5403,8 +5403,8 @@ struct _listSectionSeparatorTintModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(color, edges): - if #available(visionOS 1.0,iOS 15.0,macOS 13.0, *) { - let color = color as? AttributeReference + if #available(macOS 13.0,visionOS 1.0,iOS 15.0, *) { + let color = color as? Color.Resolvable let edges = edges as! SwiftUI.VerticalEdge.Set __content .listSectionSeparatorTint(color?.resolve(on: element, in: context), edges: edges) @@ -5439,14 +5439,14 @@ struct _listSectionSpacingModifier: ViewModifier { #if os(iOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,visionOS 1.0,watchOS 10.0, *) + @available(visionOS 1.0,iOS 17.0,watchOS 10.0, *) init(_ spacing: SwiftUI.ListSectionSpacing) { self.value = ._0(spacing: spacing) } #endif #if os(iOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,visionOS 1.0,watchOS 10.0, *) + @available(iOS 17.0,watchOS 10.0,visionOS 1.0, *) init(_ spacing: AttributeReference) { self.value = ._1(spacing: spacing) @@ -5459,7 +5459,7 @@ struct _listSectionSpacingModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) || os(watchOS) case let ._0(spacing): - if #available(iOS 17.0,visionOS 1.0,watchOS 10.0, *) { + if #available(visionOS 1.0,iOS 17.0,watchOS 10.0, *) { let spacing = spacing as! SwiftUI.ListSectionSpacing __content .listSectionSpacing(spacing) @@ -5467,7 +5467,7 @@ struct _listSectionSpacingModifier: ViewModifier { #endif #if os(iOS) || os(visionOS) || os(watchOS) case let ._1(spacing): - if #available(iOS 17.0,visionOS 1.0,watchOS 10.0, *) { + if #available(iOS 17.0,watchOS 10.0,visionOS 1.0, *) { let spacing = spacing as! AttributeReference __content .listSectionSpacing(spacing.resolve(on: element, in: context)) @@ -5583,7 +5583,7 @@ struct _menuIndicatorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - @available(iOS 15.0,macOS 12.0,tvOS 17.0,visionOS 1.0, *) + @available(iOS 15.0,tvOS 17.0,visionOS 1.0,macOS 12.0, *) init(_ visibility: AttributeReference) { self.value = ._0(visibility: visibility) @@ -5596,7 +5596,7 @@ struct _menuIndicatorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) case let ._0(visibility): - if #available(iOS 15.0,macOS 12.0,tvOS 17.0,visionOS 1.0, *) { + if #available(iOS 15.0,tvOS 17.0,visionOS 1.0,macOS 12.0, *) { let visibility = visibility as! AttributeReference __content .menuIndicator(visibility.resolve(on: element, in: context)) @@ -5669,7 +5669,7 @@ struct _menuStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - @available(macOS 11.0,iOS 14.0,tvOS 17.0,visionOS 1.0, *) + @available(iOS 14.0,tvOS 17.0,macOS 11.0,visionOS 1.0, *) init(_ style: AnyMenuStyle) { self.value = ._0(style: style) @@ -5682,7 +5682,7 @@ struct _menuStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) case let ._0(style): - if #available(macOS 11.0,iOS 14.0,tvOS 17.0,visionOS 1.0, *) { + if #available(iOS 14.0,tvOS 17.0,macOS 11.0,visionOS 1.0, *) { let style = style as! AnyMenuStyle __content .menuStyle(style) @@ -5884,7 +5884,7 @@ struct _navigationBarTitleDisplayModeModifier: ViewModifier { #if os(iOS) || os(visionOS) || os(watchOS) - @available(iOS 14.0,visionOS 1.0,watchOS 8.0, *) + @available(iOS 14.0,watchOS 8.0,visionOS 1.0, *) init(_ displayMode: SwiftUI.NavigationBarItem.TitleDisplayMode) { self.value = ._0(displayMode: displayMode) @@ -5897,7 +5897,7 @@ struct _navigationBarTitleDisplayModeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) || os(watchOS) case let ._0(displayMode): - if #available(iOS 14.0,visionOS 1.0,watchOS 8.0, *) { + if #available(iOS 14.0,watchOS 8.0,visionOS 1.0, *) { let displayMode = displayMode as! SwiftUI.NavigationBarItem.TitleDisplayMode __content .navigationBarTitleDisplayMode(displayMode) @@ -6086,21 +6086,21 @@ struct _navigationSubtitleModifier: ViewModifier { #if os(macOS) || targetEnvironment(macCatalyst) - @available(macCatalyst 14.0,macOS 11.0, *) + @available(macOS 11.0,macCatalyst 14.0, *) init(_ subtitle: TextReference) { self.value = ._0(subtitle: subtitle) } #endif #if os(macOS) || targetEnvironment(macCatalyst) - @available(macCatalyst 14.0,macOS 11.0, *) + @available(macOS 11.0,macCatalyst 14.0, *) init(_ subtitleKey: SwiftUI.LocalizedStringKey) { self.value = ._1(subtitleKey: subtitleKey) } #endif #if os(macOS) || targetEnvironment(macCatalyst) - @available(macCatalyst 14.0,macOS 11.0, *) + @available(macOS 11.0,macCatalyst 14.0, *) init(_ subtitle: AttributeReference) { self.value = ._2(subtitle: subtitle) @@ -6113,7 +6113,7 @@ struct _navigationSubtitleModifier: ViewModifier { fatalError("unreachable") #if os(macOS) || targetEnvironment(macCatalyst) case let ._0(subtitle): - if #available(macCatalyst 14.0,macOS 11.0, *) { + if #available(macOS 11.0,macCatalyst 14.0, *) { let subtitle = subtitle as! TextReference __content .navigationSubtitle(subtitle.resolve(on: element, in: context)) @@ -6121,7 +6121,7 @@ struct _navigationSubtitleModifier: ViewModifier { #endif #if os(macOS) || targetEnvironment(macCatalyst) case let ._1(subtitleKey): - if #available(macCatalyst 14.0,macOS 11.0, *) { + if #available(macOS 11.0,macCatalyst 14.0, *) { let subtitleKey = subtitleKey as! SwiftUI.LocalizedStringKey __content .navigationSubtitle(subtitleKey) @@ -6129,7 +6129,7 @@ struct _navigationSubtitleModifier: ViewModifier { #endif #if os(macOS) || targetEnvironment(macCatalyst) case let ._2(subtitle): - if #available(macCatalyst 14.0,macOS 11.0, *) { + if #available(macOS 11.0,macCatalyst 14.0, *) { let subtitle = subtitle as! AttributeReference __content .navigationSubtitle(subtitle.resolve(on: element, in: context)) @@ -6200,7 +6200,7 @@ struct _navigationTitleModifier: ViewModifier { } #if os(watchOS) - @available(macOS 11.0,watchOS 7.0,tvOS 14.0,iOS 14.0, *) + @available(tvOS 14.0,macOS 11.0,iOS 14.0,watchOS 7.0, *) init(_ title: ViewReference=ViewReference(value: [])) { self.value = ._3(title: title) @@ -6244,7 +6244,7 @@ struct _navigationTitleModifier: ViewModifier { #if os(watchOS) case let ._3(title): - if #available(macOS 11.0,watchOS 7.0,tvOS 14.0,iOS 14.0, *) { + if #available(tvOS 14.0,macOS 11.0,iOS 14.0,watchOS 7.0, *) { let title = title as! ViewReference __content .navigationTitle({ title.resolve(on: element, in: context) }) @@ -6408,7 +6408,7 @@ struct _onDeleteCommandModifier: ViewModifier { @Event private var _0_action__0: Event.EventHandler #if os(macOS) - @available(tvOS 13.0,macOS 10.15, *) + @available(macOS 10.15,tvOS 13.0, *) init(perform action__0: Event=Event()) { self.value = ._0 self.__0_action__0 = action__0 @@ -6421,7 +6421,7 @@ struct _onDeleteCommandModifier: ViewModifier { fatalError("unreachable") #if os(macOS) case ._0: - if #available(tvOS 13.0,macOS 10.15, *) { + if #available(macOS 10.15,tvOS 13.0, *) { __content .onDeleteCommand(perform: { __0_action__0.wrappedValue() }) @@ -6494,7 +6494,7 @@ struct _onExitCommandModifier: ViewModifier { @Event private var _0_action__0: Event.EventHandler #if os(macOS) || os(tvOS) - @available(tvOS 13.0,macOS 10.15, *) + @available(macOS 10.15,tvOS 13.0, *) init(perform action__0: Event=Event()) { self.value = ._0 self.__0_action__0 = action__0 @@ -6507,7 +6507,7 @@ struct _onExitCommandModifier: ViewModifier { fatalError("unreachable") #if os(macOS) || os(tvOS) case ._0: - if #available(tvOS 13.0,macOS 10.15, *) { + if #available(macOS 10.15,tvOS 13.0, *) { __content .onExitCommand(perform: { __0_action__0.wrappedValue() }) @@ -6537,7 +6537,7 @@ struct _onHoverModifier: ViewModifier { @Event private var _0_action__1: Event.EventHandler #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 13.4,macOS 10.15, *) + @available(iOS 13.4,visionOS 1.0,macOS 10.15, *) init(perform action__1: Event) { self.value = ._0 self.__0_action__1 = action__1 @@ -6550,7 +6550,7 @@ struct _onHoverModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case ._0: - if #available(visionOS 1.0,iOS 13.4,macOS 10.15, *) { + if #available(iOS 13.4,visionOS 1.0,macOS 10.15, *) { __content .onHover(perform: { __0_action__1.wrappedValue(value: $0) }) @@ -6587,7 +6587,7 @@ struct _onLongPressGestureModifier: ViewModifier { @Event private var _1_onPressingChanged__1: Event.EventHandler #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(visionOS 1.0,iOS 13.0,watchOS 6.0,macOS 10.15,tvOS 14.0, *) + @available(visionOS 1.0,tvOS 14.0,macOS 10.15,watchOS 6.0,iOS 13.0, *) init(minimumDuration: AttributeReference = .init(storage: .constant(0.5)), maximumDistance: AttributeReference = .init(storage: .constant(10)), perform action__0: Event,onPressingChanged onPressingChanged__1: Event=Event() ) { self.value = ._0(minimumDuration: minimumDuration, maximumDistance: maximumDistance) self.__0_action__0 = action__0 @@ -6595,7 +6595,7 @@ self.__0_onPressingChanged__1 = onPressingChanged__1 } #endif #if os(tvOS) - @available(iOS 13.0,watchOS 6.0,macOS 10.15,tvOS 14.0, *) + @available(tvOS 14.0,macOS 10.15,watchOS 6.0,iOS 13.0, *) init(minimumDuration: AttributeReference = .init(storage: .constant(0.5)), perform action__0: Event,onPressingChanged onPressingChanged__1: Event=Event() ) { self.value = ._1(minimumDuration: minimumDuration) self.__1_action__0 = action__0 @@ -6609,7 +6609,7 @@ self.__1_onPressingChanged__1 = onPressingChanged__1 fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(minimumDuration, maximumDistance): - if #available(visionOS 1.0,iOS 13.0,watchOS 6.0,macOS 10.15,tvOS 14.0, *) { + if #available(visionOS 1.0,tvOS 14.0,macOS 10.15,watchOS 6.0,iOS 13.0, *) { let minimumDuration = minimumDuration as! AttributeReference let maximumDistance = maximumDistance as! AttributeReference __content @@ -6618,7 +6618,7 @@ let maximumDistance = maximumDistance as! AttributeReference __content .onLongPressGesture(minimumDuration: minimumDuration.resolve(on: element, in: context), perform: { __1_action__0.wrappedValue() }, onPressingChanged: { __1_onPressingChanged__1.wrappedValue(value: $0) }) @@ -6693,7 +6693,7 @@ struct _onMoveCommandModifier: ViewModifier { @Event private var _0_action__1: Event.EventHandler #if os(macOS) || os(tvOS) - @available(tvOS 13.0,macOS 10.15, *) + @available(macOS 10.15,tvOS 13.0, *) init(perform action__1: Event=Event()) { self.value = ._0 self.__0_action__1 = action__1 @@ -6706,7 +6706,7 @@ struct _onMoveCommandModifier: ViewModifier { fatalError("unreachable") #if os(macOS) || os(tvOS) case ._0: - if #available(tvOS 13.0,macOS 10.15, *) { + if #available(macOS 10.15,tvOS 13.0, *) { __content .onMoveCommand(perform: { __0_action__1.wrappedValue(value: $0) }) @@ -6736,7 +6736,7 @@ struct _onPlayPauseCommandModifier: ViewModifier { @Event private var _0_action__0: Event.EventHandler #if os(tvOS) - @available(tvOS 13.0,macOS 10.15, *) + @available(macOS 10.15,tvOS 13.0, *) init(perform action__0: Event=Event()) { self.value = ._0 self.__0_action__0 = action__0 @@ -6749,7 +6749,7 @@ struct _onPlayPauseCommandModifier: ViewModifier { fatalError("unreachable") #if os(tvOS) case ._0: - if #available(tvOS 13.0,macOS 10.15, *) { + if #available(macOS 10.15,tvOS 13.0, *) { __content .onPlayPauseCommand(perform: { __0_action__0.wrappedValue() }) @@ -6791,7 +6791,7 @@ struct _onTapGestureModifier: ViewModifier { } #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,macOS 14.0,watchOS 10.0,visionOS 1.0, *) + @available(iOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) init(count: AttributeReference = .init(storage: .constant(1)), coordinateSpace: AnyCoordinateSpaceProtocol = .local, perform action__1: Event) { self.value = ._1(count: count, coordinateSpace: coordinateSpace) self.__1_action__1 = action__1 @@ -6812,7 +6812,7 @@ struct _onTapGestureModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._1(count, coordinateSpace): - if #available(iOS 17.0,macOS 14.0,watchOS 10.0,visionOS 1.0, *) { + if #available(iOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) { let count = count as! AttributeReference let coordinateSpace = coordinateSpace as! AnyCoordinateSpaceProtocol __content @@ -6922,10 +6922,10 @@ struct _overlayModifier: ViewModifier { indirect case _0(alignment: AttributeReference = .init(storage: .constant(.center)), content: ViewReference=ViewReference(value: [])) - indirect case _1(style: AnyShapeStyle,edges: SwiftUI.Edge.Set = .all ) + indirect case _1(style: AnyShapeStyle.Resolvable,edges: SwiftUI.Edge.Set = .all ) - indirect case _2(style: AnyShapeStyle,shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) + indirect case _2(style: AnyShapeStyle.Resolvable,shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) } @@ -6950,14 +6950,14 @@ struct _overlayModifier: ViewModifier { - init(_ style: AnyShapeStyle,ignoresSafeAreaEdges edges: SwiftUI.Edge.Set = .all ) { + init(_ style: AnyShapeStyle.Resolvable,ignoresSafeAreaEdges edges: SwiftUI.Edge.Set = .all ) { self.value = ._1(style: style, edges: edges) } - init(_ style: AnyShapeStyle,in shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { + init(_ style: AnyShapeStyle.Resolvable,in shape: AnyShape,fillStyle: SwiftUI.FillStyle = FillStyle() ) { self.value = ._2(style: style, shape: shape, fillStyle: fillStyle) } @@ -6980,7 +6980,7 @@ struct _overlayModifier: ViewModifier { __content - .overlay(style, ignoresSafeAreaEdges: edges) + .overlay(style.resolve(on: element, in: context), ignoresSafeAreaEdges: edges) @@ -6988,7 +6988,7 @@ struct _overlayModifier: ViewModifier { __content - .overlay(style, in: shape, fillStyle: fillStyle) + .overlay(style.resolve(on: element, in: context), in: shape, fillStyle: fillStyle) } @@ -7467,14 +7467,14 @@ struct _presentationBackgroundModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) - init(_ style: AnyShapeStyle) { + @available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) + init(_ style: AnyShapeStyle.Resolvable) { self.value = ._0(style: style) } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) init(alignment: AttributeReference = .init(storage: .constant(.center)), content: ViewReference=ViewReference(value: [])) { self.value = ._1(alignment: alignment, content: content) @@ -7487,15 +7487,15 @@ struct _presentationBackgroundModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(style): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { - let style = style as! AnyShapeStyle + if #available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) { + let style = style as! AnyShapeStyle.Resolvable __content - .presentationBackground(style) + .presentationBackground(style.resolve(on: element, in: context)) } else { __content } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(alignment, content): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) { let alignment = alignment as! AttributeReference let content = content as! ViewReference __content @@ -7526,7 +7526,7 @@ struct _presentationBackgroundInteractionModifier: ViewModifier #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) init(_ interaction: SwiftUI.PresentationBackgroundInteraction) { self.value = ._0(interaction: interaction) @@ -7539,7 +7539,7 @@ struct _presentationBackgroundInteractionModifier: ViewModifier fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(interaction): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) { let interaction = interaction as! SwiftUI.PresentationBackgroundInteraction __content .presentationBackgroundInteraction(interaction) @@ -7574,14 +7574,14 @@ struct _presentationCompactAdaptationModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) init(_ adaptation: SwiftUI.PresentationAdaptation) { self.value = ._0(adaptation: adaptation) } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) init(horizontal horizontalAdaptation: SwiftUI.PresentationAdaptation,vertical verticalAdaptation: SwiftUI.PresentationAdaptation) { self.value = ._1(horizontalAdaptation: horizontalAdaptation, verticalAdaptation: verticalAdaptation) @@ -7594,7 +7594,7 @@ struct _presentationCompactAdaptationModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(adaptation): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) { let adaptation = adaptation as! SwiftUI.PresentationAdaptation __content .presentationCompactAdaptation(adaptation) @@ -7602,7 +7602,7 @@ struct _presentationCompactAdaptationModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(horizontalAdaptation, verticalAdaptation): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,visionOS 1.0,macOS 13.3,tvOS 16.4,watchOS 9.4, *) { let horizontalAdaptation = horizontalAdaptation as! SwiftUI.PresentationAdaptation let verticalAdaptation = verticalAdaptation as! SwiftUI.PresentationAdaptation __content @@ -7633,7 +7633,7 @@ struct _presentationContentInteractionModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) init(_ behavior: SwiftUI.PresentationContentInteraction) { self.value = ._0(behavior: behavior) @@ -7646,7 +7646,7 @@ struct _presentationContentInteractionModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(behavior): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) { let behavior = behavior as! SwiftUI.PresentationContentInteraction __content .presentationContentInteraction(behavior) @@ -7676,7 +7676,7 @@ struct _presentationCornerRadiusModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) + @available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) init(_ cornerRadius: AttributeReference?) { self.value = ._0(cornerRadius: cornerRadius) @@ -7689,7 +7689,7 @@ struct _presentationCornerRadiusModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(cornerRadius): - if #available(iOS 16.4,macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0, *) { + if #available(iOS 16.4,tvOS 16.4,watchOS 9.4,visionOS 1.0,macOS 13.3, *) { let cornerRadius = cornerRadius as? AttributeReference __content .presentationCornerRadius(cornerRadius?.resolve(on: element, in: context)) @@ -8063,7 +8063,7 @@ struct _replaceDisabledModifier: ViewModifier { #if os(iOS) || os(visionOS) - @available(visionOS 1.0,iOS 16.0, *) + @available(iOS 16.0,visionOS 1.0, *) init(_ isDisabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isDisabled: isDisabled) @@ -8076,7 +8076,7 @@ struct _replaceDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) case let ._0(isDisabled): - if #available(visionOS 1.0,iOS 16.0, *) { + if #available(iOS 16.0,visionOS 1.0, *) { let isDisabled = isDisabled as! AttributeReference __content .replaceDisabled(isDisabled.resolve(on: element, in: context)) @@ -8552,7 +8552,7 @@ struct _scrollBounceBehaviorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 13.3,visionOS 1.0,tvOS 16.4,watchOS 9.4,iOS 16.4, *) + @available(watchOS 9.4,iOS 16.4,tvOS 16.4,macOS 13.3,visionOS 1.0, *) init(_ behavior: SwiftUI.ScrollBounceBehavior,axes: SwiftUI.Axis.Set = [.vertical] ) { self.value = ._0(behavior: behavior, axes: axes) @@ -8565,7 +8565,7 @@ struct _scrollBounceBehaviorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(behavior, axes): - if #available(macOS 13.3,visionOS 1.0,tvOS 16.4,watchOS 9.4,iOS 16.4, *) { + if #available(watchOS 9.4,iOS 16.4,tvOS 16.4,macOS 13.3,visionOS 1.0, *) { let behavior = behavior as! SwiftUI.ScrollBounceBehavior let axes = axes as! SwiftUI.Axis.Set __content @@ -8596,7 +8596,7 @@ struct _scrollClipDisabledModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) + @available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ disabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(disabled: disabled) @@ -8609,7 +8609,7 @@ struct _scrollClipDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(disabled): - if #available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) { + if #available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) { let disabled = disabled as! AttributeReference __content .scrollClipDisabled(disabled.resolve(on: element, in: context)) @@ -8639,7 +8639,7 @@ struct _scrollContentBackgroundModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(visionOS 1.0,macOS 13.0,watchOS 9.0,iOS 16.0, *) + @available(visionOS 1.0,iOS 16.0,watchOS 9.0,macOS 13.0, *) init(_ visibility: AttributeReference) { self.value = ._0(visibility: visibility) @@ -8652,7 +8652,7 @@ struct _scrollContentBackgroundModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(visibility): - if #available(visionOS 1.0,macOS 13.0,watchOS 9.0,iOS 16.0, *) { + if #available(visionOS 1.0,iOS 16.0,watchOS 9.0,macOS 13.0, *) { let visibility = visibility as! AttributeReference __content .scrollContentBackground(visibility.resolve(on: element, in: context)) @@ -8725,7 +8725,7 @@ struct _scrollDismissesKeyboardModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) - @available(macOS 13.0,tvOS 16.0,watchOS 9.0,iOS 16.0, *) + @available(watchOS 9.0,iOS 16.0,tvOS 16.0,macOS 13.0, *) init(_ mode: SwiftUI.ScrollDismissesKeyboardMode) { self.value = ._0(mode: mode) @@ -8738,7 +8738,7 @@ struct _scrollDismissesKeyboardModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) case let ._0(mode): - if #available(macOS 13.0,tvOS 16.0,watchOS 9.0,iOS 16.0, *) { + if #available(watchOS 9.0,iOS 16.0,tvOS 16.0,macOS 13.0, *) { let mode = mode as! SwiftUI.ScrollDismissesKeyboardMode __content .scrollDismissesKeyboard(mode) @@ -8816,14 +8816,14 @@ struct _scrollIndicatorsFlashModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,visionOS 1.0,tvOS 17.0,watchOS 10.0,iOS 17.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(trigger value: AttributeReference) { self.value = ._0(value: value) } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,visionOS 1.0,tvOS 17.0,watchOS 10.0,iOS 17.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(onAppear: AttributeReference) { self.value = ._1(onAppear: onAppear) @@ -8836,7 +8836,7 @@ struct _scrollIndicatorsFlashModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(value): - if #available(macOS 14.0,visionOS 1.0,tvOS 17.0,watchOS 10.0,iOS 17.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let value = value as! AttributeReference __content .scrollIndicatorsFlash(trigger: value.resolve(on: element, in: context)) @@ -8844,7 +8844,7 @@ struct _scrollIndicatorsFlashModifier: ViewModifier { #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(onAppear): - if #available(macOS 14.0,visionOS 1.0,tvOS 17.0,watchOS 10.0,iOS 17.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let onAppear = onAppear as! AttributeReference __content .scrollIndicatorsFlash(onAppear: onAppear.resolve(on: element, in: context)) @@ -8874,7 +8874,7 @@ struct _scrollPositionModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0, *) + @available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) init(id: ChangeTracked,anchor: AttributeReference? = .init(storage: .constant(nil)) ) { self.value = ._0(anchor: anchor) self.__0_id = id @@ -8887,7 +8887,7 @@ struct _scrollPositionModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(anchor): - if #available(macOS 14.0,iOS 17.0,tvOS 17.0,visionOS 1.0,watchOS 10.0, *) { + if #available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) { let anchor = anchor as? AttributeReference __content .scrollPosition(id: __0_id.projectedValue, anchor: anchor?.resolve(on: element, in: context)) @@ -8917,7 +8917,7 @@ struct _scrollTargetBehaviorModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) + @available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ behavior: AnyScrollTargetBehavior) { self.value = ._0(behavior: behavior) @@ -8930,7 +8930,7 @@ struct _scrollTargetBehaviorModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(behavior): - if #available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) { + if #available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) { let behavior = behavior as! AnyScrollTargetBehavior __content .scrollTargetBehavior(behavior) @@ -8960,7 +8960,7 @@ struct _scrollTargetLayoutModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) + @available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) init(isEnabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isEnabled: isEnabled) @@ -8973,7 +8973,7 @@ struct _scrollTargetLayoutModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(isEnabled): - if #available(watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0,tvOS 17.0, *) { + if #available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) { let isEnabled = isEnabled as! AttributeReference __content .scrollTargetLayout(isEnabled: isEnabled.resolve(on: element, in: context)) @@ -9046,7 +9046,7 @@ struct _searchPresentationToolbarBehaviorModifier: ViewModifier #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.1,watchOS 10.1,iOS 17.1,tvOS 17.1,visionOS 1.0, *) + @available(watchOS 10.1,iOS 17.1,tvOS 17.1,macOS 14.1,visionOS 1.0, *) init(_ behavior: SwiftUI.SearchPresentationToolbarBehavior) { self.value = ._0(behavior: behavior) @@ -9059,7 +9059,7 @@ struct _searchPresentationToolbarBehaviorModifier: ViewModifier fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(behavior): - if #available(macOS 14.1,watchOS 10.1,iOS 17.1,tvOS 17.1,visionOS 1.0, *) { + if #available(watchOS 10.1,iOS 17.1,tvOS 17.1,macOS 14.1,visionOS 1.0, *) { let behavior = behavior as! SwiftUI.SearchPresentationToolbarBehavior __content .searchPresentationToolbarBehavior(behavior) @@ -9201,7 +9201,7 @@ struct _searchableModifier: ViewModifier { } #if os(iOS) || os(macOS) || os(visionOS) - @available(iOS 17.0,visionOS 1.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0,visionOS 1.0, *) init(text: ChangeTracked,isPresented: ChangeTracked,placement: SwiftUI.SearchFieldPlacement = .automatic, prompt: TextReference? = nil ) { self.value = ._3(placement: placement, prompt: prompt) self.__3_text = text @@ -9209,7 +9209,7 @@ self.__3_isPresented = isPresented } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(iOS 17.0,visionOS 1.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0,visionOS 1.0, *) init(text: ChangeTracked,isPresented: ChangeTracked,placement: SwiftUI.SearchFieldPlacement = .automatic, prompt: SwiftUI.LocalizedStringKey) { self.value = ._4(placement: placement, prompt: prompt) self.__4_text = text @@ -9217,7 +9217,7 @@ self.__4_isPresented = isPresented } #endif #if os(iOS) || os(macOS) || os(visionOS) - @available(iOS 17.0,visionOS 1.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0,visionOS 1.0, *) init(text: ChangeTracked,isPresented: ChangeTracked,placement: SwiftUI.SearchFieldPlacement = .automatic, prompt: AttributeReference) { self.value = ._5(placement: placement, prompt: prompt) self.__5_text = text @@ -9255,7 +9255,7 @@ self.__5_isPresented = isPresented #if os(iOS) || os(macOS) || os(visionOS) case let ._3(placement, prompt): - if #available(iOS 17.0,visionOS 1.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0,visionOS 1.0, *) { let placement = placement as! SwiftUI.SearchFieldPlacement let prompt = prompt as? TextReference __content @@ -9264,7 +9264,7 @@ let prompt = prompt as? TextReference #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._4(placement, prompt): - if #available(iOS 17.0,visionOS 1.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0,visionOS 1.0, *) { let placement = placement as! SwiftUI.SearchFieldPlacement let prompt = prompt as! SwiftUI.LocalizedStringKey __content @@ -9273,7 +9273,7 @@ let prompt = prompt as! SwiftUI.LocalizedStringKey #endif #if os(iOS) || os(macOS) || os(visionOS) case let ._5(placement, prompt): - if #available(iOS 17.0,visionOS 1.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0,visionOS 1.0, *) { let placement = placement as! SwiftUI.SearchFieldPlacement let prompt = prompt as! AttributeReference __content @@ -9304,7 +9304,7 @@ struct _selectionDisabledModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) + @available(tvOS 17.0,iOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(_ isDisabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isDisabled: isDisabled) @@ -9317,7 +9317,7 @@ struct _selectionDisabledModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(isDisabled): - if #available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) { + if #available(tvOS 17.0,iOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let isDisabled = isDisabled as! AttributeReference __content .selectionDisabled(isDisabled.resolve(on: element, in: context)) @@ -9334,7 +9334,7 @@ struct _shadowModifier: ViewModifier { enum Value { case _never - indirect case _0(color: AttributeReference = .init(storage: .constant(Color(.sRGBLinear, white: 0, opacity: 0.33))), radius: AttributeReference,x: AttributeReference = .init(storage: .constant(0)), y: AttributeReference = .init(storage: .constant(0)) ) + indirect case _0(color: Color.Resolvable = .init(Color(.sRGBLinear, white: 0, opacity: 0.33)), radius: AttributeReference,x: AttributeReference = .init(storage: .constant(0)), y: AttributeReference = .init(storage: .constant(0)) ) } @@ -9348,7 +9348,7 @@ struct _shadowModifier: ViewModifier { - init(color: AttributeReference = .init(storage: .constant(Color(.sRGBLinear, white: 0, opacity: 0.33))), radius: AttributeReference,x: AttributeReference = .init(storage: .constant(0)), y: AttributeReference = .init(storage: .constant(0)) ) { + init(color: Color.Resolvable = .init(Color(.sRGBLinear, white: 0, opacity: 0.33)), radius: AttributeReference,x: AttributeReference = .init(storage: .constant(0)), y: AttributeReference = .init(storage: .constant(0)) ) { self.value = ._0(color: color, radius: radius, x: x, y: y) } @@ -9649,7 +9649,7 @@ struct _statusBarHiddenModifier: ViewModifier { #if os(iOS) || os(visionOS) - @available(visionOS 1.0,iOS 13.0, *) + @available(iOS 13.0,visionOS 1.0, *) init(_ hidden: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(hidden: hidden) @@ -9662,7 +9662,7 @@ struct _statusBarHiddenModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(visionOS) case let ._0(hidden): - if #available(visionOS 1.0,iOS 13.0, *) { + if #available(iOS 13.0,visionOS 1.0, *) { let hidden = hidden as! AttributeReference __content .statusBarHidden(hidden.resolve(on: element, in: context)) @@ -9778,7 +9778,7 @@ struct _swipeActionsModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) - @available(visionOS 1.0,macOS 12.0,watchOS 8.0,iOS 15.0, *) + @available(iOS 15.0,watchOS 8.0,visionOS 1.0,macOS 12.0, *) init(edge: SwiftUI.HorizontalEdge = .trailing, allowsFullSwipe: AttributeReference = .init(storage: .constant(true)), content: ViewReference=ViewReference(value: [])) { self.value = ._0(edge: edge, allowsFullSwipe: allowsFullSwipe, content: content) @@ -9791,7 +9791,7 @@ struct _swipeActionsModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) case let ._0(edge, allowsFullSwipe, content): - if #available(visionOS 1.0,macOS 12.0,watchOS 8.0,iOS 15.0, *) { + if #available(iOS 15.0,watchOS 8.0,visionOS 1.0,macOS 12.0, *) { let edge = edge as! SwiftUI.HorizontalEdge let allowsFullSwipe = allowsFullSwipe as! AttributeReference let content = content as! ViewReference @@ -9828,14 +9828,14 @@ struct _symbolEffectModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(_ effect: AnyIndefiniteSymbolEffect,options: Symbols.SymbolEffectOptions = .default, isActive: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(effect: effect, options: options, isActive: isActive) } #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(_ effect: AnyDiscreteSymbolEffect,options: Symbols.SymbolEffectOptions = .default, value: AttributeReference) { self.value = ._1(effect: effect, options: options, value: value) @@ -9848,7 +9848,7 @@ struct _symbolEffectModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(effect, options, isActive): - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let effect = effect as! AnyIndefiniteSymbolEffect let options = options as! Symbols.SymbolEffectOptions let isActive = isActive as! AttributeReference @@ -9858,7 +9858,7 @@ let isActive = isActive as! AttributeReference #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(effect, options, value): - if #available(watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0,macOS 14.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let effect = effect as! AnyDiscreteSymbolEffect let options = options as! Symbols.SymbolEffectOptions let value = value as! AttributeReference @@ -9890,7 +9890,7 @@ struct _symbolEffectsRemovedModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) + @available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) init(_ isEnabled: AttributeReference = .init(storage: .constant(true)) ) { self.value = ._0(isEnabled: isEnabled) @@ -9903,7 +9903,7 @@ struct _symbolEffectsRemovedModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(isEnabled): - if #available(macOS 14.0,watchOS 10.0,iOS 17.0,tvOS 17.0,visionOS 1.0, *) { + if #available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) { let isEnabled = isEnabled as! AttributeReference __content .symbolEffectsRemoved(isEnabled.resolve(on: element, in: context)) @@ -10105,7 +10105,7 @@ struct _tableStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 16.0, *) + @available(iOS 16.0,visionOS 1.0,macOS 12.0, *) init(_ style: AnyTableStyle) { self.value = ._0(style: style) @@ -10118,7 +10118,7 @@ struct _tableStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(style): - if #available(macOS 12.0,visionOS 1.0,iOS 16.0, *) { + if #available(iOS 16.0,visionOS 1.0,macOS 12.0, *) { let style = style as! AnyTableStyle __content .tableStyle(style) @@ -10191,7 +10191,7 @@ struct _textContentTypeModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) - @available(tvOS 13.0,visionOS 1.0,iOS 13.0, *) + @available(tvOS 13.0,iOS 13.0,visionOS 1.0, *) init(_ textContentType: UIKit.UITextContentType?) { self.value = ._0(textContentType: textContentType) @@ -10204,7 +10204,7 @@ struct _textContentTypeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) case let ._0(textContentType): - if #available(tvOS 13.0,visionOS 1.0,iOS 13.0, *) { + if #available(tvOS 13.0,iOS 13.0,visionOS 1.0, *) { let textContentType = textContentType as? UIKit.UITextContentType __content .textContentType(textContentType) @@ -10234,7 +10234,7 @@ struct _textEditorStyleModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(visionOS 1.0,iOS 17.0,macOS 14.0, *) + @available(macOS 14.0,iOS 17.0,visionOS 1.0, *) init(_ style: AnyTextEditorStyle) { self.value = ._0(style: style) @@ -10247,7 +10247,7 @@ struct _textEditorStyleModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(style): - if #available(visionOS 1.0,iOS 17.0,macOS 14.0, *) { + if #available(macOS 14.0,iOS 17.0,visionOS 1.0, *) { let style = style as! AnyTextEditorStyle __content .textEditorStyle(style) @@ -10320,7 +10320,7 @@ struct _textInputAutocapitalizationModifier: ViewModifier { #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(watchOS 8.0,visionOS 1.0,iOS 15.0,tvOS 15.0, *) + @available(watchOS 8.0,visionOS 1.0,tvOS 15.0,iOS 15.0, *) init(_ autocapitalization: SwiftUI.TextInputAutocapitalization?) { self.value = ._0(autocapitalization: autocapitalization) @@ -10333,7 +10333,7 @@ struct _textInputAutocapitalizationModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(autocapitalization): - if #available(watchOS 8.0,visionOS 1.0,iOS 15.0,tvOS 15.0, *) { + if #available(watchOS 8.0,visionOS 1.0,tvOS 15.0,iOS 15.0, *) { let autocapitalization = autocapitalization as? SwiftUI.TextInputAutocapitalization __content .textInputAutocapitalization(autocapitalization) @@ -10363,7 +10363,7 @@ struct _textSelectionModifier: ViewModifier { #if os(iOS) || os(macOS) || os(visionOS) - @available(macOS 12.0,visionOS 1.0,iOS 15.0, *) + @available(iOS 15.0,visionOS 1.0,macOS 12.0, *) init(_ selectability: AnyTextSelectability) { self.value = ._0(selectability: selectability) @@ -10376,7 +10376,7 @@ struct _textSelectionModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(visionOS) case let ._0(selectability): - if #available(macOS 12.0,visionOS 1.0,iOS 15.0, *) { + if #available(iOS 15.0,visionOS 1.0,macOS 12.0, *) { let selectability = selectability as! AnyTextSelectability __content .textSelection(selectability) @@ -10393,10 +10393,10 @@ struct _tintModifier: ViewModifier { enum Value { case _never - indirect case _0(tint: AnyShapeStyle) + indirect case _0(tint: AnyShapeStyle.Resolvable) - indirect case _1(tint: AttributeReference?) + indirect case _1(tint: Color.Resolvable?) } @@ -10412,14 +10412,14 @@ struct _tintModifier: ViewModifier { - init(_ tint: AnyShapeStyle) { + init(_ tint: AnyShapeStyle.Resolvable) { self.value = ._0(tint: tint) } - init(_ tint: AttributeReference?) { + init(_ tint: Color.Resolvable?) { self.value = ._1(tint: tint) } @@ -10434,7 +10434,7 @@ struct _tintModifier: ViewModifier { __content - .tint(tint) + .tint(tint.resolve(on: element, in: context)) @@ -10534,7 +10534,7 @@ struct _toolbarModifier: ViewModifier { } #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) + @available(visionOS 1.0,tvOS 17.0,macOS 14.0,watchOS 10.0,iOS 17.0, *) init(removing defaultItemKind: SwiftUI.ToolbarDefaultItemKind?) { self.value = ._1(defaultItemKind: defaultItemKind) @@ -10569,7 +10569,7 @@ struct _toolbarModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._1(defaultItemKind): - if #available(macOS 14.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,iOS 17.0, *) { + if #available(visionOS 1.0,tvOS 17.0,macOS 14.0,watchOS 10.0,iOS 17.0, *) { let defaultItemKind = defaultItemKind as? SwiftUI.ToolbarDefaultItemKind __content .toolbar(removing: defaultItemKind) @@ -10602,7 +10602,7 @@ struct _toolbarBackgroundModifier: ViewModifier { enum Value { case _never - indirect case _0(style: AnyShapeStyle,bars: SwiftUI.ToolbarPlacement) + indirect case _0(style: AnyShapeStyle.Resolvable,bars: SwiftUI.ToolbarPlacement) indirect case _1(visibility: AttributeReference,bars: SwiftUI.ToolbarPlacement) @@ -10621,7 +10621,7 @@ struct _toolbarBackgroundModifier: ViewModifier { - init(_ style: AnyShapeStyle,for bars: SwiftUI.ToolbarPlacement) { + init(_ style: AnyShapeStyle.Resolvable,for bars: SwiftUI.ToolbarPlacement) { self.value = ._0(style: style, bars: bars) } @@ -10643,7 +10643,7 @@ struct _toolbarBackgroundModifier: ViewModifier { __content - .toolbarBackground(style, for: bars) + .toolbarBackground(style.resolve(on: element, in: context), for: bars) @@ -10764,7 +10764,7 @@ struct _toolbarTitleDisplayModeModifier: ViewModifier { #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) - @available(iOS 17.0,macOS 14.0,tvOS 17.0,visionOS 1.0,watchOS 10.0, *) + @available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) init(_ mode: SwiftUI.ToolbarTitleDisplayMode) { self.value = ._0(mode: mode) @@ -10777,7 +10777,7 @@ struct _toolbarTitleDisplayModeModifier: ViewModifier { fatalError("unreachable") #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) case let ._0(mode): - if #available(iOS 17.0,macOS 14.0,tvOS 17.0,visionOS 1.0,watchOS 10.0, *) { + if #available(iOS 17.0,tvOS 17.0,watchOS 10.0,visionOS 1.0,macOS 14.0, *) { let mode = mode as! SwiftUI.ToolbarTitleDisplayMode __content .toolbarTitleDisplayMode(mode) @@ -12030,16 +12030,16 @@ indirect case chunk12(_BuiltinModifierChunk12) indirect case chunk13(_BuiltinModifierChunk13) indirect case chunk14(_BuiltinModifierChunk14) indirect case chunk15(_BuiltinModifierChunk15) - indirect case _PrefersDefaultFocusModifier(LiveViewNative._PrefersDefaultFocusModifier) -indirect case _MaskModifier(LiveViewNative._MaskModifier) -indirect case _OnSubmitModifier(LiveViewNative._OnSubmitModifier) + indirect case _MaskModifier(LiveViewNative._MaskModifier) indirect case _PresentationDetentsModifier(LiveViewNative._PresentationDetentsModifier) indirect case _SearchScopesModifier(LiveViewNative._SearchScopesModifier) -indirect case _PerspectiveRotationEffectModifier(LiveViewNative._PerspectiveRotationEffectModifier) -indirect case _Rotation3DEffectModifier(LiveViewNative._Rotation3DEffectModifier) -indirect case _SearchCompletionModifier(LiveViewNative._SearchCompletionModifier) indirect case _MatchedGeometryEffectModifier(LiveViewNative._MatchedGeometryEffectModifier) +indirect case _OnSubmitModifier(LiveViewNative._OnSubmitModifier) +indirect case _SearchCompletionModifier(LiveViewNative._SearchCompletionModifier) +indirect case _Rotation3DEffectModifier(LiveViewNative._Rotation3DEffectModifier) +indirect case _PerspectiveRotationEffectModifier(LiveViewNative._PerspectiveRotationEffectModifier) indirect case _FocusScopeModifier(LiveViewNative._FocusScopeModifier) +indirect case _PrefersDefaultFocusModifier(LiveViewNative._PrefersDefaultFocusModifier) indirect case _customRegistryModifier(R.CustomModifier) indirect case _anyTextModifier(_AnyTextModifier) indirect case _anyImageModifier(_AnyImageModifier) @@ -12080,25 +12080,25 @@ case let .chunk14(chunk): content.modifier(chunk) case let .chunk15(chunk): content.modifier(chunk) - case let ._PrefersDefaultFocusModifier(modifier): - content.modifier(modifier) -case let ._MaskModifier(modifier): - content.modifier(modifier) -case let ._OnSubmitModifier(modifier): + case let ._MaskModifier(modifier): content.modifier(modifier) case let ._PresentationDetentsModifier(modifier): content.modifier(modifier) case let ._SearchScopesModifier(modifier): content.modifier(modifier) -case let ._PerspectiveRotationEffectModifier(modifier): +case let ._MatchedGeometryEffectModifier(modifier): content.modifier(modifier) -case let ._Rotation3DEffectModifier(modifier): +case let ._OnSubmitModifier(modifier): content.modifier(modifier) case let ._SearchCompletionModifier(modifier): content.modifier(modifier) -case let ._MatchedGeometryEffectModifier(modifier): +case let ._Rotation3DEffectModifier(modifier): + content.modifier(modifier) +case let ._PerspectiveRotationEffectModifier(modifier): content.modifier(modifier) case let ._FocusScopeModifier(modifier): + content.modifier(modifier) +case let ._PrefersDefaultFocusModifier(modifier): content.modifier(modifier) case let ._customRegistryModifier(modifier): content.modifier(modifier) @@ -12342,16 +12342,16 @@ _truncationModeModifier.name: _truncationModeModifier.parser(in: context). _unredactedModifier.name: _unredactedModifier.parser(in: context).map({ Output.chunk15(.unredacted($0)) }).eraseToAnyParser(), _upperLimbVisibilityModifier.name: _upperLimbVisibilityModifier.parser(in: context).map({ Output.chunk15(.upperLimbVisibility($0)) }).eraseToAnyParser(), _zIndexModifier.name: _zIndexModifier.parser(in: context).map({ Output.chunk15(.zIndex($0)) }).eraseToAnyParser(), - LiveViewNative._PrefersDefaultFocusModifier.name: LiveViewNative._PrefersDefaultFocusModifier.parser(in: context).map(Output._PrefersDefaultFocusModifier).eraseToAnyParser(), -LiveViewNative._MaskModifier.name: LiveViewNative._MaskModifier.parser(in: context).map(Output._MaskModifier).eraseToAnyParser(), -LiveViewNative._OnSubmitModifier.name: LiveViewNative._OnSubmitModifier.parser(in: context).map(Output._OnSubmitModifier).eraseToAnyParser(), + LiveViewNative._MaskModifier.name: LiveViewNative._MaskModifier.parser(in: context).map(Output._MaskModifier).eraseToAnyParser(), LiveViewNative._PresentationDetentsModifier.name: LiveViewNative._PresentationDetentsModifier.parser(in: context).map(Output._PresentationDetentsModifier).eraseToAnyParser(), LiveViewNative._SearchScopesModifier.name: LiveViewNative._SearchScopesModifier.parser(in: context).map(Output._SearchScopesModifier).eraseToAnyParser(), -LiveViewNative._PerspectiveRotationEffectModifier.name: LiveViewNative._PerspectiveRotationEffectModifier.parser(in: context).map(Output._PerspectiveRotationEffectModifier).eraseToAnyParser(), -LiveViewNative._Rotation3DEffectModifier.name: LiveViewNative._Rotation3DEffectModifier.parser(in: context).map(Output._Rotation3DEffectModifier).eraseToAnyParser(), -LiveViewNative._SearchCompletionModifier.name: LiveViewNative._SearchCompletionModifier.parser(in: context).map(Output._SearchCompletionModifier).eraseToAnyParser(), LiveViewNative._MatchedGeometryEffectModifier.name: LiveViewNative._MatchedGeometryEffectModifier.parser(in: context).map(Output._MatchedGeometryEffectModifier).eraseToAnyParser(), +LiveViewNative._OnSubmitModifier.name: LiveViewNative._OnSubmitModifier.parser(in: context).map(Output._OnSubmitModifier).eraseToAnyParser(), +LiveViewNative._SearchCompletionModifier.name: LiveViewNative._SearchCompletionModifier.parser(in: context).map(Output._SearchCompletionModifier).eraseToAnyParser(), +LiveViewNative._Rotation3DEffectModifier.name: LiveViewNative._Rotation3DEffectModifier.parser(in: context).map(Output._Rotation3DEffectModifier).eraseToAnyParser(), +LiveViewNative._PerspectiveRotationEffectModifier.name: LiveViewNative._PerspectiveRotationEffectModifier.parser(in: context).map(Output._PerspectiveRotationEffectModifier).eraseToAnyParser(), LiveViewNative._FocusScopeModifier.name: LiveViewNative._FocusScopeModifier.parser(in: context).map(Output._FocusScopeModifier).eraseToAnyParser(), +LiveViewNative._PrefersDefaultFocusModifier.name: LiveViewNative._PrefersDefaultFocusModifier.parser(in: context).map(Output._PrefersDefaultFocusModifier).eraseToAnyParser(), ] let deprecations = [ @@ -12628,7 +12628,7 @@ ConstantAtomLiteral("vertical").map({ () -> Self in /// * `.standard` /// * `.increased` @_documentation(visibility: public) -@available(macOS 14.0,iOS 17.0,visionOS 1.0, *) +@available(iOS 17.0,visionOS 1.0,macOS 14.0, *) extension BadgeProminence: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -12898,7 +12898,7 @@ ConstantAtomLiteral("plusLighter").map({ () -> Self in /// * `.enabled` /// * `.disabled` @_documentation(visibility: public) -@available(visionOS 1.0,watchOS 10.0,macOS 14.0,tvOS 17.0,iOS 17.0, *) +@available(tvOS 17.0,iOS 17.0,visionOS 1.0,watchOS 10.0,macOS 14.0, *) extension ButtonRepeatBehavior: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13022,14 +13022,14 @@ ConstantAtomLiteral("dark").map({ () -> Self in /// * `.tabView` /// * `.navigation` @_documentation(visibility: public) -@available(iOS 17.0,tvOS 17.0,macOS 14.0,watchOS 10.0,visionOS 1.0, *) +@available(iOS 17.0,watchOS 10.0,visionOS 1.0,tvOS 17.0,macOS 14.0, *) extension ContainerBackgroundPlacement: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { OneOf { ConstantAtomLiteral("tabView").map({ () -> Self in #if os(watchOS) -if #available(iOS 17.0,tvOS 17.0,macOS 14.0,watchOS 10.0, *) { +if #available(iOS 17.0,watchOS 10.0,tvOS 17.0,macOS 14.0, *) { return Self.tabView } else { fatalError("'tabView' is not available in this OS version") } #else @@ -13038,7 +13038,7 @@ fatalError("'tabView' is not available on this OS") }) ConstantAtomLiteral("navigation").map({ () -> Self in #if os(watchOS) -if #available(iOS 17.0,tvOS 17.0,macOS 14.0,watchOS 10.0, *) { +if #available(iOS 17.0,watchOS 10.0,tvOS 17.0,macOS 14.0, *) { return Self.navigation } else { fatalError("'navigation' is not available in this OS version") } #else @@ -13058,7 +13058,7 @@ fatalError("'navigation' is not available on this OS") /// * `.scrollContent` /// * `.scrollIndicators` @_documentation(visibility: public) -@available(iOS 17.0,tvOS 17.0,macOS 14.0,watchOS 10.0,visionOS 1.0, *) +@available(watchOS 10.0,iOS 17.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) extension ContentMarginPlacement: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13122,7 +13122,7 @@ extension ContentShapeKinds: ParseableModifierValue { }) ConstantAtomLiteral("dragPreview").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) -if #available(iOS 15.0,tvOS 15.0,macOS 12.0,watchOS 8.0,visionOS 1.0, *) { +if #available(iOS 15.0,macOS 12.0,tvOS 15.0,watchOS 8.0,visionOS 1.0, *) { return Self.dragPreview } else { fatalError("'dragPreview' is not available in this OS version") } #else @@ -13131,7 +13131,7 @@ fatalError("'dragPreview' is not available on this OS") }) ConstantAtomLiteral("contextMenuPreview").map({ () -> Self in #if os(iOS) || os(tvOS) || os(visionOS) -if #available(iOS 15.0,tvOS 17.0,macOS 12.0,watchOS 8.0,visionOS 1.0, *) { +if #available(iOS 15.0,macOS 12.0,tvOS 17.0,watchOS 8.0,visionOS 1.0, *) { return Self.contextMenuPreview } else { fatalError("'contextMenuPreview' is not available in this OS version") } #else @@ -13140,7 +13140,7 @@ fatalError("'contextMenuPreview' is not available on this OS") }) ConstantAtomLiteral("hoverEffect").map({ () -> Self in #if os(iOS) || os(visionOS) -if #available(iOS 15.0,tvOS 15.0,macOS 12.0,watchOS 8.0,visionOS 1.0, *) { +if #available(iOS 15.0,macOS 12.0,tvOS 15.0,watchOS 8.0,visionOS 1.0, *) { return Self.hoverEffect } else { fatalError("'hoverEffect' is not available in this OS version") } #else @@ -13149,7 +13149,7 @@ fatalError("'hoverEffect' is not available on this OS") }) ConstantAtomLiteral("focusEffect").map({ () -> Self in #if os(macOS) || os(watchOS) -if #available(iOS 15.0,tvOS 15.0,macOS 12.0,watchOS 8.0, *) { +if #available(iOS 15.0,macOS 12.0,tvOS 15.0,watchOS 8.0, *) { return Self.focusEffect } else { fatalError("'focusEffect' is not available in this OS version") } #else @@ -13158,7 +13158,7 @@ fatalError("'focusEffect' is not available on this OS") }) ConstantAtomLiteral("accessibility").map({ () -> Self in #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) -if #available(macOS 14.0,visionOS 1.0,iOS 17.0,watchOS 10.0,tvOS 17.0, *) { +if #available(macOS 14.0,iOS 17.0,visionOS 1.0,watchOS 10.0,tvOS 17.0, *) { return Self.accessibility } else { fatalError("'accessibility' is not available in this OS version") } #else @@ -13180,7 +13180,7 @@ fatalError("'accessibility' is not available on this OS") /// * `.large` /// * `.extraLarge` @_documentation(visibility: public) -@available(watchOS 9.0,macOS 10.15,visionOS 1.0,iOS 15.0, *) +@available(watchOS 9.0,iOS 15.0,macOS 10.15,visionOS 1.0, *) extension ControlSize: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13214,7 +13214,7 @@ fatalError("'regular' is not available on this OS") }) ConstantAtomLiteral("large").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) -if #available(watchOS 9.0,macOS 11.0,visionOS 1.0,iOS 15.0, *) { +if #available(watchOS 9.0,iOS 15.0,macOS 11.0,visionOS 1.0, *) { return Self.large } else { fatalError("'large' is not available in this OS version") } #else @@ -13223,7 +13223,7 @@ fatalError("'large' is not available on this OS") }) ConstantAtomLiteral("extraLarge").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) || os(watchOS) -if #available(watchOS 10.0,macOS 14.0,visionOS 1.0,iOS 17.0, *) { +if #available(watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) { return Self.extraLarge } else { fatalError("'extraLarge' is not available in this OS version") } #else @@ -13278,7 +13278,7 @@ ConstantAtomLiteral("userInitiated").map({ () -> Self in /// * `.critical` /// * `.standard` @_documentation(visibility: public) -@available(iOS 17.0,watchOS 10.0,tvOS 17.0,macOS 13.0,visionOS 1.0, *) +@available(tvOS 17.0,watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 13.0, *) extension DialogSeverity: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13303,7 +13303,7 @@ fatalError("'critical' is not available on this OS") }) ConstantAtomLiteral("standard").map({ () -> Self in #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) -if #available(macOS 14.0,watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0, *) { +if #available(tvOS 17.0,watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0, *) { return Self.standard } else { fatalError("'standard' is not available in this OS version") } #else @@ -13488,7 +13488,7 @@ ConstantAtomLiteral("all").map({ () -> Self in /// * `.includeHiddenFiles` /// * `.displayFileExtensions` @_documentation(visibility: public) -@available(macOS 14.0,iOS 17.0,visionOS 1.0, *) +@available(iOS 17.0,visionOS 1.0,macOS 14.0, *) extension FileDialogBrowserOptions: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13533,7 +13533,7 @@ fatalError("'displayFileExtensions' is not available on this OS") /// * `.edit` /// * `.automatic` @_documentation(visibility: public) -@available(iOS 17.0,watchOS 10.0,tvOS 17.0,visionOS 1.0,macOS 14.0, *) +@available(iOS 17.0,watchOS 10.0,visionOS 1.0,tvOS 17.0,macOS 14.0, *) extension FocusInteractions: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13714,7 +13714,7 @@ ConstantAtomLiteral("trailing").map({ () -> Self in }) ConstantAtomLiteral("listRowSeparatorLeading").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) -if #available(macOS 13.0,iOS 16.0,visionOS 1.0, *) { +if #available(iOS 16.0,visionOS 1.0,macOS 13.0, *) { return Self.listRowSeparatorLeading } else { fatalError("'listRowSeparatorLeading' is not available in this OS version") } #else @@ -13723,7 +13723,7 @@ fatalError("'listRowSeparatorLeading' is not available on this OS") }) ConstantAtomLiteral("listRowSeparatorTrailing").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) -if #available(macOS 13.0,iOS 16.0,visionOS 1.0, *) { +if #available(iOS 16.0,visionOS 1.0,macOS 13.0, *) { return Self.listRowSeparatorTrailing } else { fatalError("'listRowSeparatorTrailing' is not available in this OS version") } #else @@ -13778,7 +13778,7 @@ ConstantAtomLiteral("trailing").map({ () -> Self in /// * `.highlight` /// * `.lift` @_documentation(visibility: public) -@available(visionOS 1.0,iOS 13.4,tvOS 16.0, *) +@available(tvOS 16.0,visionOS 1.0,iOS 13.4, *) extension HoverEffect: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -13794,7 +13794,7 @@ fatalError("'automatic' is not available on this OS") }) ConstantAtomLiteral("highlight").map({ () -> Self in #if os(iOS) || os(tvOS) || os(visionOS) -if #available(visionOS 1.0,iOS 13.4,tvOS 17.0, *) { +if #available(tvOS 17.0,visionOS 1.0,iOS 13.4, *) { return Self.highlight } else { fatalError("'highlight' is not available in this OS version") } #else @@ -13919,7 +13919,7 @@ extension MenuOrder: ParseableModifierValue { }) ConstantAtomLiteral("priority").map({ () -> Self in #if os(iOS) || os(visionOS) -if #available(watchOS 9.0,visionOS 1.0,macOS 13.0,tvOS 16.0,iOS 16.0, *) { +if #available(tvOS 16.0,watchOS 9.0,iOS 16.0,macOS 13.0,visionOS 1.0, *) { return Self.priority } else { fatalError("'priority' is not available in this OS version") } #else @@ -13950,7 +13950,7 @@ ConstantAtomLiteral("fixed").map({ () -> Self in /// * `.sheet` /// * `.fullScreenCover` @_documentation(visibility: public) -@available(iOS 16.4,watchOS 9.4,tvOS 16.4,macOS 13.3,visionOS 1.0, *) +@available(macOS 13.3,iOS 16.4,watchOS 9.4,tvOS 16.4,visionOS 1.0, *) extension PresentationAdaptation: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14013,7 +14013,7 @@ fatalError("'fullScreenCover' is not available on this OS") /// * `.resizes` /// * `.scrolls` @_documentation(visibility: public) -@available(visionOS 1.0,watchOS 9.4,macOS 13.3,tvOS 16.4,iOS 16.4, *) +@available(tvOS 16.4,iOS 16.4,visionOS 1.0,watchOS 9.4,macOS 13.3, *) extension PresentationContentInteraction: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14118,7 +14118,7 @@ ConstantAtomLiteral("privacy").map({ () -> Self in }) ConstantAtomLiteral("invalidated").map({ () -> Self in #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) -if #available(watchOS 10.0,visionOS 1.0,macOS 14.0,tvOS 17.0,iOS 17.0, *) { +if #available(iOS 17.0,macOS 14.0,tvOS 17.0,watchOS 10.0,visionOS 1.0, *) { return Self.invalidated } else { fatalError("'invalidated' is not available in this OS version") } #else @@ -14233,7 +14233,7 @@ extension ScenePadding: ParseableModifierValue { }) ConstantAtomLiteral("navigationBar").map({ () -> Self in #if os(watchOS) -if #available(iOS 16.0,tvOS 16.0,macOS 13.0,watchOS 9.0, *) { +if #available(watchOS 9.0,iOS 16.0,tvOS 16.0,macOS 13.0, *) { return Self.navigationBar } else { fatalError("'navigationBar' is not available in this OS version") } #else @@ -14253,7 +14253,7 @@ fatalError("'navigationBar' is not available on this OS") /// * `.always` /// * `.basedOnSize` @_documentation(visibility: public) -@available(iOS 16.4,tvOS 16.4,macOS 13.3,watchOS 9.4,visionOS 1.0, *) +@available(iOS 16.4,watchOS 9.4,visionOS 1.0,tvOS 16.4,macOS 13.3, *) extension ScrollBounceBehavior: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14299,7 +14299,7 @@ fatalError("'basedOnSize' is not available on this OS") /// * `.interactively` /// * `.never` @_documentation(visibility: public) -@available(iOS 16.0,tvOS 16.0,macOS 13.0,watchOS 9.0, *) +@available(iOS 16.0,watchOS 9.0,tvOS 16.0,macOS 13.0, *) extension ScrollDismissesKeyboardMode: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14408,7 +14408,7 @@ ConstantAtomLiteral("never").map({ () -> Self in /// * `.onTextEntry` /// * `.onSearchPresentation` @_documentation(visibility: public) -@available(macOS 13.3,tvOS 16.4,watchOS 9.4,visionOS 1.0,iOS 16.4, *) +@available(macOS 13.3,iOS 16.4,watchOS 9.4,tvOS 16.4,visionOS 1.0, *) extension SearchScopeActivation: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14498,7 +14498,7 @@ ConstantAtomLiteral("content").map({ () -> Self in /// * `.enabled` /// * `.disabled` @_documentation(visibility: public) -@available(watchOS 10.0,visionOS 1.0,macOS 14.0,tvOS 17.0,iOS 17.0, *) +@available(tvOS 17.0,watchOS 10.0,iOS 17.0,macOS 14.0,visionOS 1.0, *) extension SpringLoadingBehavior: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14681,7 +14681,7 @@ ConstantAtomLiteral("search").map({ () -> Self in /// Possible values: /// * `.sidebarToggle` @_documentation(visibility: public) -@available(macOS 14.0,watchOS 10.0,visionOS 1.0,tvOS 17.0,iOS 17.0, *) +@available(tvOS 17.0,watchOS 10.0,visionOS 1.0,iOS 17.0,macOS 14.0, *) extension ToolbarDefaultItemKind: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { @@ -14725,7 +14725,7 @@ extension ToolbarRole: ParseableModifierValue { }) ConstantAtomLiteral("navigationStack").map({ () -> Self in #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) -if #available(iOS 16.0,watchOS 9.0,tvOS 16.0,visionOS 1.0,macOS 13.0, *) { +if #available(iOS 16.0,watchOS 9.0,visionOS 1.0,tvOS 16.0,macOS 13.0, *) { return Self.navigationStack } else { fatalError("'navigationStack' is not available in this OS version") } #else @@ -14734,7 +14734,7 @@ fatalError("'navigationStack' is not available on this OS") }) ConstantAtomLiteral("browser").map({ () -> Self in #if os(iOS) || os(visionOS) -if #available(iOS 16.0,watchOS 9.0,tvOS 16.0,visionOS 1.0,macOS 13.0, *) { +if #available(iOS 16.0,watchOS 9.0,visionOS 1.0,tvOS 16.0,macOS 13.0, *) { return Self.browser } else { fatalError("'browser' is not available in this OS version") } #else @@ -14743,7 +14743,7 @@ fatalError("'browser' is not available on this OS") }) ConstantAtomLiteral("editor").map({ () -> Self in #if os(iOS) || os(macOS) || os(visionOS) -if #available(iOS 16.0,watchOS 9.0,tvOS 16.0,visionOS 1.0,macOS 13.0, *) { +if #available(iOS 16.0,watchOS 9.0,visionOS 1.0,tvOS 16.0,macOS 13.0, *) { return Self.editor } else { fatalError("'editor' is not available in this OS version") } #else @@ -14764,7 +14764,7 @@ fatalError("'editor' is not available on this OS") /// * `.inlineLarge` /// * `.inline` @_documentation(visibility: public) -@available(iOS 17.0,watchOS 10.0,tvOS 17.0,macOS 14.0,visionOS 1.0, *) +@available(visionOS 1.0,iOS 17.0,watchOS 10.0,tvOS 17.0,macOS 14.0, *) extension ToolbarTitleDisplayMode: ParseableModifierValue { public static func parser(in context: ParseableModifierContext) -> some Parser { ImplicitStaticMember { diff --git a/Sources/ModifierGenerator/ModifierGenerator.swift b/Sources/ModifierGenerator/ModifierGenerator.swift index f44a49d88..5f510a9b8 100644 --- a/Sources/ModifierGenerator/ModifierGenerator.swift +++ b/Sources/ModifierGenerator/ModifierGenerator.swift @@ -290,9 +290,12 @@ struct ModifierGenerator: ParsableCommand { }) let requiresContext = signatures.contains(where: { $0.parameters.contains(where: { - ["ViewReference", "TextReference", "AttributeReference", "InlineViewReference"].contains( + ["ViewReference", "TextReference", "AttributeReference", "InlineViewReference", "AnyShapeStyle", "Color", "ListItemTint"].contains( $0.type.as(IdentifierTypeSyntax.self)?.name.text - ?? $0.type.as(OptionalTypeSyntax.self)?.wrappedType.as(IdentifierTypeSyntax.self)?.name.text + ?? $0.type.as(OptionalTypeSyntax.self)?.wrappedType.as(IdentifierTypeSyntax.self)?.name.text + ?? $0.type.as(MemberTypeSyntax.self)?.baseType.as(IdentifierTypeSyntax.self)?.name.text + ?? $0.type.as(OptionalTypeSyntax.self)?.wrappedType + .as(MemberTypeSyntax.self)?.baseType.as(IdentifierTypeSyntax.self)?.name.text ) }) }) diff --git a/Sources/ModifierGenerator/Signature.swift b/Sources/ModifierGenerator/Signature.swift index aade2ca49..5243ecdcf 100644 --- a/Sources/ModifierGenerator/Signature.swift +++ b/Sources/ModifierGenerator/Signature.swift @@ -100,12 +100,10 @@ struct Signature { ) __content .\#(decl.name.trimmed.text)(\#(parameters.map({ - switch $0.type.as(IdentifierTypeSyntax.self)?.name.text { + switch ($0.type.as(IdentifierTypeSyntax.self)?.name ?? $0.type.as(MemberTypeSyntax.self)?.baseType.as(IdentifierTypeSyntax.self)?.name)?.text { case "ViewReference", "ToolbarContentReference", "CustomizableToolbarContentReference": return $0.firstName.tokenKind == .wildcard ? "{ \($0.secondName!.text).resolve(on: element, in: context) }" : "\($0.firstName.text): { \(($0.secondName ?? $0.firstName).text).resolve(on: element, in: context) }" - case "InlineViewReference": - return $0.firstName.tokenKind == .wildcard ? "\($0.secondName!.text).resolve(on: element, in: context)" : "\($0.firstName.text): \(($0.secondName ?? $0.firstName).text).resolve(on: element, in: context)" - case "TextReference": + case "InlineViewReference", "TextReference", "AttributeReference", "AnyShapeStyle", "Color", "ListItemTint": return $0.firstName.tokenKind == .wildcard ? "\($0.secondName!.text).resolve(on: element, in: context)" : "\($0.firstName.text): \(($0.secondName ?? $0.firstName).text).resolve(on: element, in: context)" case "ChangeTracked": // These are registered on the View so they get proper DynamicProperty treatment. @@ -122,14 +120,11 @@ struct Signature { let arguments = (0..") } else if self.as(IdentifierTypeSyntax.self)?.name.text == "View" { return TypeSyntax("InlineViewReference") + } else if self.as(IdentifierTypeSyntax.self)?.name.text == "ShapeStyle" { + return TypeSyntax("AnyShapeStyle.Resolvable") } else { // add `Any*` prefix to erase return TypeSyntax("Any\(self)") @@ -244,6 +241,37 @@ extension FunctionParameterSyntax { .with(\.secondName, "\(raw: parameter.secondName?.text ?? parameter.firstName.text)__\(raw: String(functionType.parameters.count))") // encode the number of parameters into the `secondName`. } else if let genericBaseType { self = parameter.with(\.type, genericBaseType.resolveGenericType()) + } else if let memberType = parameter.type.as(MemberTypeSyntax.self) ?? parameter.type.as(OptionalTypeSyntax.self)?.wrappedType.as(MemberTypeSyntax.self), + memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == "SwiftUI" + && memberType.name.text == "Color" + { + self = parameter + .with( + \.type, + TypeSyntax("Color.Resolvable\(raw: parameter.type.is(OptionalTypeSyntax.self) ? "? " : "")") + .with(\.leadingTrivia, memberType.leadingTrivia) + .with(\.trailingTrivia, memberType.trailingTrivia) + ) + .with(\.defaultValue, parameter.defaultValue.flatMap({ + .init( + leadingTrivia: $0.leadingTrivia, + equal: $0.equal, + value: ExprSyntax(".init(\($0.value))") + .with(\.leadingTrivia, $0.value.leadingTrivia) + .with(\.trailingTrivia, $0.value.trailingTrivia), + trailingTrivia: $0.trailingTrivia + ) + })) + } else if let memberType = parameter.type.as(MemberTypeSyntax.self) ?? parameter.type.as(OptionalTypeSyntax.self)?.wrappedType.as(MemberTypeSyntax.self), + memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == "SwiftUI" + && memberType.name.text == "ListItemTint" + { + self = parameter.with( + \.type, + TypeSyntax("ListItemTint.Resolvable\(raw: parameter.type.is(OptionalTypeSyntax.self) ? "? " : "")") + .with(\.leadingTrivia, memberType.leadingTrivia) + .with(\.trailingTrivia, memberType.trailingTrivia) + ) } else if let memberType = parameter.type.as(MemberTypeSyntax.self) ?? parameter.type.as(OptionalTypeSyntax.self)?.wrappedType.as(MemberTypeSyntax.self), memberType.baseType.as(IdentifierTypeSyntax.self)?.name.text == "SwiftUI" && memberType.name.text == "Text" @@ -289,7 +317,6 @@ extension FunctionParameterSyntax { // SwiftUI types "Alignment", "Angle", - "Color", "ColorScheme", "HorizontalAlignment", "RoundedCornerStyle", diff --git a/Sources/ModifierGenerator/Subcommands/DocumentationExtensions.swift b/Sources/ModifierGenerator/Subcommands/DocumentationExtensions.swift index 7760dbf53..c837dce94 100644 --- a/Sources/ModifierGenerator/Subcommands/DocumentationExtensions.swift +++ b/Sources/ModifierGenerator/Subcommands/DocumentationExtensions.swift @@ -16,7 +16,7 @@ extension ModifierGenerator { static let configuration = CommandConfiguration(abstract: "Output a list of the names of all available modifiers.") @Option( - help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface`", + help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/XROS.platform/Developer/SDKs/XROS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-xros.swiftinterface`", transform: { URL(filePath: $0) } ) var interface: URL @@ -156,25 +156,19 @@ extension ModifierGenerator { } var result = "" + let style: String if parameters.isEmpty { - result.append(#""" - ```elixir - # stylesheet - "example" do - \#(name)() - end - ``` - """#) + style = #"\#(name)()"# } else { - result.append(#""" - ```elixir - # stylesheet - "example" do - \#(name)(\#(parameters.joined(separator: ", "))) - end - ``` - """#) + style = #"\#(name)(\#(parameters.joined(separator: ", ")))"# + } + + let quotedStyle: String + if style.contains(#"""# as Character) { + quotedStyle = #"'\#(style)'"# + } else { + quotedStyle = #""\#(style)""# } let changeEvent: String? = switch changeTracked.count { @@ -195,16 +189,14 @@ extension ModifierGenerator { result.append(#""" ```html - <%!-- template --%> - + ``` """#) } else { result.append(#""" ```html - <%!-- template --%> - + \#(templates.map({ " \($0)" }).joined(separator: "\n")) ``` @@ -214,8 +206,7 @@ extension ModifierGenerator { result.append(#""" ```html - <%!-- template --%> - + \#(templates.map({ " \($0)" }).joined(separator: "\n")) ``` @@ -227,7 +218,6 @@ extension ModifierGenerator { result.append(#""" ```elixir - # LiveView \#(resolvedEvents.map({ #"def handle_event("\#($0)", params, socket)"# }).joined(separator: "\n")) ``` """#) diff --git a/Sources/ModifierGenerator/Subcommands/List.swift b/Sources/ModifierGenerator/Subcommands/List.swift index e39f2a051..d86a2eb6d 100644 --- a/Sources/ModifierGenerator/Subcommands/List.swift +++ b/Sources/ModifierGenerator/Subcommands/List.swift @@ -9,7 +9,7 @@ extension ModifierGenerator { static let configuration = CommandConfiguration(abstract: "Output a list of the names of all available modifiers.") @Option( - help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface`", + help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/XROS.platform/Developer/SDKs/XROS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-xros.swiftinterface`", transform: { URL(filePath: $0) } ) var interface: URL? diff --git a/Sources/ModifierGenerator/Subcommands/Schema.swift b/Sources/ModifierGenerator/Subcommands/Schema.swift index 0793b80ee..617b760fb 100644 --- a/Sources/ModifierGenerator/Subcommands/Schema.swift +++ b/Sources/ModifierGenerator/Subcommands/Schema.swift @@ -9,7 +9,7 @@ extension ModifierGenerator { static let configuration = CommandConfiguration(abstract: "Generate a `stylesheet-language.json` file compatible with the VS Code extension.") @Option( - help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface`", + help: "The `.swiftinterface` file from `/Applications/Xcode.app/Contents/Developer/Platforms/XROS.platform/Developer/SDKs/XROS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-xros.swiftinterface`", transform: { URL(filePath: $0) } ) var interface: URL? diff --git a/Tests/LiveViewNativeStylesheetTests/ShapeStyleTests.swift b/Tests/LiveViewNativeStylesheetTests/ShapeStyleTests.swift index 92b24cc87..c5cb4fa51 100644 --- a/Tests/LiveViewNativeStylesheetTests/ShapeStyleTests.swift +++ b/Tests/LiveViewNativeStylesheetTests/ShapeStyleTests.swift @@ -34,7 +34,7 @@ extension XCTestCase { _ value: T ) where T: Equatable, T: ParseableModifierValue { testParserEqual(ast, value) - testParser(ast, as: AnyShapeStyle.self) + testParser(ast, as: AnyShapeStyle.Resolvable.self) } @_disfavoredOverload @@ -42,7 +42,7 @@ extension XCTestCase { _ ast: String, _ value: T ) where T: ParseableModifierValue { - testParser(ast, as: AnyShapeStyle.self) + testParser(ast, as: AnyShapeStyle.Resolvable.self) } } @@ -51,41 +51,41 @@ final class ShapeStyleTests: XCTestCase { // static members testParserShapeStyle( #"{:., [], [:Color, :pink]}"#, - Color.pink + Color.Resolvable(.pink) ) testParserShapeStyle( #"{:., [], [nil, :pink]}"#, - Color.pink + Color.Resolvable(.pink) ) // inits testParserShapeStyle( #"{:Color, [], [{:., [], [nil, :sRGB]}, [red: 0.4627, green: 0.8392, blue: 1.0]]}"#, - Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0) + Color.Resolvable(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) ) testParserShapeStyle( #"{:Color, [], [{:., [], [nil, :sRGB]}, [white: 0.5, opacity: 0.5]]}"#, - Color(.sRGB, white: 0.5, opacity: 0.5) + Color.Resolvable(Color(.sRGB, white: 0.5, opacity: 0.5)) ) testParserShapeStyle( #"{:Color, [], [[hue: 1, saturation: 0.5, brightness: 0.25, opacity: 0.75]]}"#, - Color(hue: 1, saturation: 0.5, brightness: 0.25, opacity: 0.75) + Color.Resolvable(Color(hue: 1, saturation: 0.5, brightness: 0.25, opacity: 0.75)) ) testParserShapeStyle( #"{:Color, [], [[red: 0.852, green: 0.646, blue: 0.847]]}"#, - Color(red: 0.852, green: 0.646, blue: 0.847) + Color.Resolvable(Color(red: 0.852, green: 0.646, blue: 0.847)) ) // modifiers testParserShapeStyle( #"{:., [], [nil, {:., [], [:pink, {:opacity, [], [0.5]}]}]}"#, - Color.pink.opacity(0.5) + Color.Resolvable(Color.pink.opacity(0.5)) ) testParserShapeStyle( #"{:., [], [:Color, {:., [], [:pink, {:opacity, [], [0.5]}]}]}"#, - Color.pink.opacity(0.5) + Color.Resolvable(Color.pink.opacity(0.5)) ) testParserShapeStyle( #"{:., [], [{:Color, [], [{:., [], [nil, :displayP3]}, [red: 0.4627, green: 0.8392, blue: 1.0]]}, {:opacity, [], [0.25]}]}"#, - Color(.displayP3, red: 0.4627, green: 0.8392, blue: 1.0).opacity(0.25) + Color.Resolvable(Color(.displayP3, red: 0.4627, green: 0.8392, blue: 1.0).opacity(0.25)) ) } @@ -93,77 +93,57 @@ final class ShapeStyleTests: XCTestCase { // AnyGradient testParserShapeStyle( #"{:., [], [:Color, {:., [], [:red, :gradient]}]}"#, - Color.red.gradient + AnyShapeStyle.Resolvable(Color.red.gradient) ) testParserShapeStyle( #"{:., [], [:Color, {:., [], [:pink, {:., [], [{:opacity, [], [0.5]}, :gradient]}]}]}"#, - Color.pink.opacity(0.5).gradient + AnyShapeStyle.Resolvable(Color.pink.opacity(0.5).gradient) ) // Gradient testParserShapeStyle( #"{:Gradient, [], [[colors: [{:., [], [nil, :red]}, {:., [], [nil, :blue]}]]]}"#, - Gradient(colors: [.red, .blue]) + AnyShapeStyle.Resolvable(Gradient(colors: [.red, .blue])) ) testParserShapeStyle( #"{:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}"#, - Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]) + AnyShapeStyle.Resolvable(Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)])) ) // angularGradient testParserShapeStyle( #"{:., [], [nil, {:angularGradient, [], [{:., [], [:Color, {:., [], [:red, :gradient]}]}, [center: {:., [], [nil, :center]}, startAngle: {:., [], [nil, :zero]}, endAngle: {:., [], [nil, {:degrees, [], [270]}]}]]}]}"#, - AnyShapeStyle(AngularGradient.angularGradient(Color.red.gradient, startAngle: .zero, endAngle: .degrees(270))) - ) - testParserShapeStyle( - #"{:AngularGradient, [], [[gradient: {:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}, center: {:., [], [nil, :center]}, startAngle: {:., [], [nil, :zero]}, endAngle: {:., [], [nil, {:degrees, [], [270]}]}]]}"#, - AngularGradient(gradient: Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]), center: .center, startAngle: .zero, endAngle: .degrees(270)) + AnyShapeStyle.Resolvable(AngularGradient.angularGradient(Color.red.gradient, startAngle: .zero, endAngle: .degrees(270))) ) // conicGradient testParserShapeStyle( #"{:., [], [nil, {:conicGradient, [], [{:., [], [:Color, {:., [], [:red, :gradient]}]}, [center: {:., [], [nil, :center]}, angle: {:., [], [nil, {:degrees, [], [270]}]}]]}]}"#, - AnyShapeStyle(AngularGradient.conicGradient(Color.red.gradient, center: .center, angle: .degrees(270))) - ) - testParserShapeStyle( - #"{:AngularGradient, [], [[gradient: {:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}, center: {:., [], [nil, :center]}, angle: {:., [], [nil, {:degrees, [], [270]}]}]]}"#, - AngularGradient(gradient: Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]), center: .center, angle: .degrees(270)) + AnyShapeStyle.Resolvable(AngularGradient.conicGradient(Color.red.gradient, center: .center, angle: .degrees(270))) ) // ellipticalGradient testParserShapeStyle( #"{:., [], [nil, {:ellipticalGradient, [], [{:., [], [:Color, {:., [], [:red, :gradient]}]}, [center: {:., [], [nil, :center]}, startRadiusFraction: 0, endRadiusFraction: 1]]}]}"#, - AnyShapeStyle(EllipticalGradient.ellipticalGradient(Color.red.gradient, center: .center, startRadiusFraction: 0, endRadiusFraction: 1)) - ) - testParserShapeStyle( - #"{:EllipticalGradient, [], [[gradient: {:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}, center: {:., [], [nil, :center]}, startRadiusFraction: 0, endRadiusFraction: 1]]}"#, - EllipticalGradient(gradient: Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]), center: .center, startRadiusFraction: 0, endRadiusFraction: 1) + AnyShapeStyle.Resolvable(EllipticalGradient.ellipticalGradient(Color.red.gradient, center: .center, startRadiusFraction: 0, endRadiusFraction: 1)) ) // linearGradient testParserShapeStyle( #"{:., [], [nil, {:linearGradient, [], [{:., [], [:Color, {:., [], [:red, :gradient]}]}, [startPoint: {:., [], [nil, :leading]}, endPoint: {:., [], [nil, :trailing]}]]}]}"#, - AnyShapeStyle(LinearGradient.linearGradient(Color.red.gradient, startPoint: .leading, endPoint: .trailing)) - ) - testParserShapeStyle( - #"{:LinearGradient, [], [[gradient: {:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}, startPoint: {:., [], [nil, :leading]}, endPoint: {:., [], [nil, :trailing]}]]}"#, - LinearGradient(gradient: Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]), startPoint: .leading, endPoint: .trailing) + AnyShapeStyle.Resolvable(LinearGradient.linearGradient(Color.red.gradient, startPoint: .leading, endPoint: .trailing)) ) // radialGradient testParserShapeStyle( #"{:., [], [nil, {:radialGradient, [], [{:., [], [:Color, {:., [], [:red, :gradient]}]}, [center: {:., [], [nil, :center]}, startRadius: 0.5, endRadius: 1]]}]}"#, - AnyShapeStyle(RadialGradient.radialGradient(Color.red.gradient, center: .center, startRadius: 0.5, endRadius: 1)) - ) - testParserShapeStyle( - #"{:RadialGradient, [], [[gradient: {:Gradient, [], [[stops: [{:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :red]}, location: 0.5]]}]}, {:., [], [:Gradient, {:Stop, [], [[color: {:., [], [nil, :blue]}, location: 1]]}]}]]]}, center: {:., [], [nil, :center]}, startRadius: 0.5, endRadius: 1]]}"#, - RadialGradient(gradient: Gradient(stops: [Gradient.Stop(color: .red, location: 0.5), Gradient.Stop(color: .blue, location: 1)]), center: .center, startRadius: 0.5, endRadius: 1) + AnyShapeStyle.Resolvable(RadialGradient.radialGradient(Color.red.gradient, center: .center, startRadius: 0.5, endRadius: 1)) ) } func testHierarchical() { testParserShapeStyle( #"{:., [], [nil, :tertiary]}"#, - HierarchicalShapeStyle.tertiary + AnyShapeStyle.Resolvable(HierarchicalShapeStyle.tertiary) ) if #available(macOS 14.0, iOS 17, watchOS 10, tvOS 17, visionOS 1, *) { testParserShapeStyle( #"{:., [], [:Color, {:., [], [:red, :quaternary]}]}"#, - AnyShapeStyle(Color.red.quaternary) + AnyShapeStyle.Resolvable(Color.red.quaternary) ) } } @@ -172,11 +152,11 @@ final class ShapeStyleTests: XCTestCase { if #available(iOS 15, macOS 12, tvOS 15, watchOS 10, visionOS 1, *) { testParserShapeStyle( #"{:., [], [nil, :regularMaterial]}"#, - AnyShapeStyle(Material.regularMaterial) + AnyShapeStyle.Resolvable(Material.regularMaterial) ) testParserShapeStyle( #"{:., [], [nil, :ultraThickMaterial]}"#, - AnyShapeStyle(Material.ultraThickMaterial) + AnyShapeStyle.Resolvable(Material.ultraThickMaterial) ) } } @@ -184,55 +164,55 @@ final class ShapeStyleTests: XCTestCase { func testImagePaint() { testParserShapeStyle( #"{:., [], [nil, {:image, [], [{:Image, [], ["test"]}, [sourceRect: {:CGRect, [], [[x: 0, y: 0, width: 1, height: 1]]}, scale: 1]]}]}"#, - AnyShapeStyle(.image(Image("test"), sourceRect: CGRect(x: 0, y: 0, width: 1, height: 1), scale: 1)) + AnyShapeStyle.Resolvable(.image(Image("test"), sourceRect: CGRect(x: 0, y: 0, width: 1, height: 1), scale: 1)) ) testParserShapeStyle( #"{:ImagePaint, [], [[image: {:Image, [], ["test"]}, sourceRect: {:CGRect, [], [[x: 0, y: 0, width: 1, height: 1]]}, scale: 1]]}"#, - ImagePaint(image: Image("test"), sourceRect: CGRect(x: 0, y: 0, width: 1, height: 1), scale: 1) + AnyShapeStyle.Resolvable(ImagePaint(image: Image("test"), sourceRect: CGRect(x: 0, y: 0, width: 1, height: 1), scale: 1)) ) } func testSemanticStyles() { testParserShapeStyle( #"{:., [], [nil, :foreground]}"#, - AnyShapeStyle(.foreground) + AnyShapeStyle.Resolvable(.foreground) ) testParserShapeStyle( #"{:., [], [nil, :background]}"#, - AnyShapeStyle(.background) + AnyShapeStyle.Resolvable(.background) ) #if !os(watchOS) testParserShapeStyle( #"{:., [], [nil, :selection]}"#, - AnyShapeStyle(.selection) + AnyShapeStyle.Resolvable(.selection) ) #endif testParserShapeStyle( #"{:., [], [nil, :tint]}"#, - AnyShapeStyle(.tint) + AnyShapeStyle.Resolvable(.tint) ) if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) { testParserShapeStyle( #"{:., [], [nil, :separator]}"#, - AnyShapeStyle(.separator) + AnyShapeStyle.Resolvable(.separator) ) testParserShapeStyle( #"{:., [], [nil, :placeholder]}"#, - AnyShapeStyle(.placeholder) + AnyShapeStyle.Resolvable(.placeholder) ) testParserShapeStyle( #"{:., [], [nil, :link]}"#, - AnyShapeStyle(.link) + AnyShapeStyle.Resolvable(.link) ) testParserShapeStyle( #"{:., [], [nil, :fill]}"#, - AnyShapeStyle(.fill) + AnyShapeStyle.Resolvable(.fill) ) } if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { testParserShapeStyle( #"{:., [], [nil, :windowBackground]}"#, - AnyShapeStyle(.windowBackground) + AnyShapeStyle.Resolvable(.windowBackground) ) } } @@ -240,28 +220,28 @@ final class ShapeStyleTests: XCTestCase { func testStyleModifiers() { testParserShapeStyle( #"{:., [], [nil, {:blendMode, [], [{:., [], [nil, :multiply]}]}]}"#, - AnyShapeStyle(.blendMode(.multiply)) + AnyShapeStyle.Resolvable(.blendMode(.multiply)) ) testParserShapeStyle( #"{:., [], [nil, {:opacity, [], [0.5]}]}"#, - AnyShapeStyle(.opacity(0.5)) + AnyShapeStyle.Resolvable(.opacity(0.5)) ) testParserShapeStyle( #"{:., [], [nil, {:shadow, [], [{:., [], [nil, {:drop, [], [[color: {:Color, [], [{:., [], [nil, :sRGBLinear]}, [white: 0, opacity: 0.33]]}, radius: 5, x: 0, y: 0]]}]}]}]}"#, - AnyShapeStyle(.shadow(.drop(color: Color(.sRGBLinear, white: 0, opacity: 0.33), radius: 5, x: 0, y: 0))) + AnyShapeStyle.Resolvable(.shadow(.drop(color: Color(.sRGBLinear, white: 0, opacity: 0.33), radius: 5, x: 0, y: 0))) ) testParserShapeStyle( #"{:., [], [nil, {:., [], [:foreground, {:blendMode, [], [{:., [], [nil, :multiply]}]}]}]}"#, - AnyShapeStyle(.foreground.blendMode(.multiply)) + AnyShapeStyle.Resolvable(.foreground.blendMode(.multiply)) ) testParserShapeStyle( #"{:., [], [nil, {:., [], [:foreground, {:opacity, [], [0.5]}]}]}"#, - AnyShapeStyle(.foreground.opacity(0.5)) + AnyShapeStyle.Resolvable(.foreground.opacity(0.5)) ) testParserShapeStyle( #"{:., [], [nil, {:shadow, [], [{:., [], [nil, {:drop, [], [[color: {:Color, [], [{:., [], [nil, :sRGBLinear]}, [white: 0, opacity: 0.33]]}, radius: 5, x: 0, y: 0]]}]}]}]}"#, - AnyShapeStyle(.foreground.shadow(.inner(color: Color(.sRGBLinear, white: 0, opacity: 0.55), radius: 5, x: 0, y: 0))) + AnyShapeStyle.Resolvable(.foreground.shadow(.inner(color: Color(.sRGBLinear, white: 0, opacity: 0.55), radius: 5, x: 0, y: 0))) ) } } diff --git a/guides/ex_doc_notebooks/create-a-swiftui-application.md b/guides/ex_doc_notebooks/create-a-swiftui-application.md new file mode 100644 index 000000000..134e8af89 --- /dev/null +++ b/guides/ex_doc_notebooks/create-a-swiftui-application.md @@ -0,0 +1,213 @@ + + +# Create a SwiftUI Application + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%create-a-swiftui-application.livemd) + +## Overview + +This guide will teach you how to set up a SwiftUI Application for LiveView Native. + +Typically, we recommend using the `mix lvn.install` task as described in the [Installation Guide](https://hexdocs.pm/live_view_native/installation.html#5-enable-liveview-native) to add LiveView Native to a Phoenix project. However, we will walk through the steps of manually setting up an Xcode iOS project to learn how the iOS side of a LiveView Native application works. + +In future lessons, you'll use this iOS application to view iOS examples in the Xcode simulator (or a physical device if you prefer.) + +## Prerequisites + +First, make sure you have followed the [Getting Started](https://hexdocs.pm/live_view_native/getting_started.md) guide. Then evaluate the smart cell below and visit http://localhost:4000 to ensure the Phoenix server runs properly. You should see the text `Hello from LiveView!` + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Create the iOS Application + +Open Xcode and select Create New Project. + + + +![Xcode Create New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-new-project.png?raw=true) + + + +Select the `iOS` and `App` options to create an iOS application. Then click `Next`. + + + +![Xcode Create Template For New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-template-for-new-project.png?raw=true) + + + +Choose options for your new project that match the following image, then click `Next`. + +### What do these options mean? + +* **Product Name:** The name of the application. This can be any valid name. We've chosen `Guides`. +* **Organization Identifier:** A reverse DNS string that uniquely identifies your organization. If you don't have a company identifier, [Apple recomends](https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app) using `com.example.your_name` where `your_name` is your organization or personal name. +* **Interface:**: The Xcode user interface to use. Select **SwiftUI** to create an app that uses the SwiftUI app lifecycle. +* **Language:** Determines which language Xcode should use for the project. Select `Swift`. + + + + +![Xcode Choose Options For Your New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-choose-options-for-your-new-project.png?raw=true) + + + +Select an appropriate folder location where you would like to store the iOS project, then click `Create`. + + + +![Xcode select folder location](https://github.com/liveview-native/documentation_assets/blob/main/xcode-select-folder-location.png?raw=true) + + + +You should see the default iOS application generated by Xcode. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/default-xcode-app.png?raw=true) + +## Add the LiveView Client SwiftUI Package + +In Xcode from the project you just created, select `File -> Add Package Dependencies`. Then, search for `liveview-client-swiftui`. Once you have selected the package, click `Add Package`. + +The image below was created using version `0.2.0`. You should select whichever is the latest version of LiveView Native. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/add-liveview-swiftui-client-package-0.2.0.png?raw=true) + + + +Choose the Package Products for `liveview-client-swiftui`. Select `Guides` as the target for `LiveViewNative` and `LiveViewNativeStylesheet`. This adds both of these dependencies to your iOS project. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/select-package-products.png?raw=true) + + + +At this point, you'll need to enable permissions for plugins used by LiveView Native. +You should see the following prompt. Click `Trust & Enable All`. + + + +![Xcode some build plugins are disabled](https://github.com/liveview-native/documentation_assets/blob/main/xcode-some-build-plugins-are-disabled.png?raw=true) + + + +You'll also need to manually navigate to the error tab (shown below) and manually trust and enable packages. Click on each error to trigger a prompt. Select `Trust & Enable All` to enable the plugin. + +The specific plugins are subject to change. At the time of writing you need to enable `LiveViewNativeStylesheetMacros`, `LiveViewNativeMacros`, and `CasePathMacros` as shown in the images below. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-stylesheet.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-macros.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-case-path-macros.png?raw=true) + +## Setup the SwiftUI LiveView + +The [ContentView](https://developer.apple.com/tutorials/swiftui-concepts/exploring-the-structure-of-a-swiftui-app#Content-view) contains the main view of our iOS application. + +Replace the code in the `ContentView` file with the following to connect the SwiftUI application and the Phoenix application. + + + +```swift +import SwiftUI +import LiveViewNative + +struct ContentView: View { + + var body: some View { + LiveView(.automatic( + development: .localhost(path: "/"), + production: .custom(URL(string: "https://example.com/")!) + )) + } +} + + +// Optionally preview the native UI in Xcode +#Preview { + ContentView() +} +``` + + + +The code above sets up the SwiftUI LiveView. By default, the SwiftUI LiveView connects to any Phoenix app running on http://localhost:4000. + + + + + +```mermaid +graph LR; + subgraph I[iOS App] + direction TB + ContentView + SL[SwiftUI LiveView] + end + subgraph P[Phoenix App] + LiveView + end + SL --> P + ContentView --> SL + + +``` + +## Start the Active Scheme + +Click the `start active scheme` button to build the project and run it on the iOS simulator. + +> A [build scheme](https://developer.apple.com/documentation/xcode/build-system) contains a list of targets to build, and any configuration and environment details that affect the selected action. For example, when you build and run an app, the scheme tells Xcode what launch arguments to pass to the app. +> +> * https://developer.apple.com/documentation/xcode/build-system + +After you start the active scheme, the simulator should open the iOS application and display `Hello from LiveView Native!`. If you encounter any issues see the **Troubleshooting** section below. + + + +
+ +
+ +## Troubleshooting + +If you encountered any issues with the native application, here are some troubleshooting steps you can use: + +* **Reset Package Caches:** In the Xcode application go to `File -> Packages -> Reset Package Caches`. +* **Update Packages:** In the Xcode application go to `File -> Packages -> Update to Latest Package Versions`. +* **Rebuild the Active Scheme**: In the Xcode application, press the `start active scheme` button to rebuild the active scheme and run it on the Xcode simulator. +* Update your [Xcode](https://developer.apple.com/xcode/) version if it is not already the latest version +* Check for error messages in the Livebook smart cells. + +You can also [raise an issue](https://github.com/liveview-native/live_view_native/issues/new) if you would like support from the LiveView Native team. diff --git a/guides/ex_doc_notebooks/forms-and-validation.md b/guides/ex_doc_notebooks/forms-and-validation.md new file mode 100644 index 000000000..c6db3323f --- /dev/null +++ b/guides/ex_doc_notebooks/forms-and-validation.md @@ -0,0 +1,640 @@ +# Forms and Validation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%forms-and-validation.livemd) + +## Overview + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) project makes it easier to build forms in LiveView Native. This project enables you to group different [Control Views](https://developer.apple.com/documentation/swiftui/controls-and-indicators) inside of a `LiveForm` and control them collectively under a single `phx-change` or `phx-submit` event handler, rather than with multiple different `phx-change` event handlers. + +Getting the most out of this material requires some understanding of the [Ecto](https://hexdocs.pm/ecto/Ecto.html) project and in particular a reasonably deep understanding of [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). Review the linked Ecto documentation if you find any of the examples difficult to follow. + +## Installing LiveView Native Live Form + +To install LiveView Native Form, we need to add the `liveview-native-live-form` SwiftUI package to our iOS application. + +Follow the [LiveView Native Form Installation Guide](https://github.com/liveview-native/liveview-native-live-form?tab=readme-ov-file#liveviewnativeliveform) on that project's README and come back to this guide after you have finished the installation process. + +## Creating a Basic Form + +Once you have the LiveView Native Form package installed, you can use the `LiveForm` and `LiveSubmitButton` views to build forms more conveniently. + +Here's a basic example of a `LiveForm`. Keep in mind that `LiveForm` requires an `id` attribute. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.ExampleLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +When a form is submitted, its data is sent as a map where each key is the 'name' attribute of the form's control views. Evaluate the example above in your simulator and you will see a map similar to the following: + + + +```elixir +%{"my-text" => "some value"} +``` + +In a real-world application you could use these params to trigger some application logic, such as inserting a record into the database. + +## Controls and Indicators + +We've already covered many individual controls and indicator views that you can use inside of forms. For more information on those, go to the [Interactive SwiftUI Views](https://hexdocs.pm/live_view_native/interactive-swiftui-views.html) guide. + + + +### Your Turn + +Create a form that has `TextField`, `Slider`, `Toggle`, and `DatePicker` fields. + +### Example Solution + +```elixir +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + + + + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +``` + + + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + # You may use this handler to test your solution. + # You should not need to modify this handler. + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Controlled Values + +Some control views such as the `Stepper` require manually displaying their value. In this case, we can store the form params in the socket and update them everytime the `phx-change` form binding submits an event. You can also use this pattern to provide default values. + +Evaluate the example below to see this in action. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.StepperLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, params: %{"my-stepper" => 1})} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + <%= @params["my-stepper"] %> + + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, assign(socket, params: params)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Secure Field + +For password entry, or anytime you want to hide a given value, you can use the [SecureField](https://developer.apple.com/documentation/swiftui/securefield) view. This field works mostly the same as a `TextField` but hides the visual text. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.SecureLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + Enter a Password + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Keyboard Types + +To format a `TextField` for specific input types we can use the [keyboardType](https://developer.apple.com/documentation/swiftui/view/keyboardtype(_:)) modifier. + +For a complete list of accepted keyboard types, see the [UIKeyboardType](https://developer.apple.com/documentation/uikit/uikeyboardtype) documentation. + +Below we've created several different common keyboard types. We've also included a generic `keyboard-*` to demonstrate how you can make a reusable class. + +```elixir +defmodule KeyboardStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "number-pad" do + keyboardType(.numberPad) + end + + "email-address" do + keyboardType(.emailAddress) + end + + "phone-pad" do + keyboardType(.phonePad) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +Evaluate the example below to see the different keyboards as you focus on each input. If you don't see the keyboard, go to `I/O` -> `Keyboard` -> `Toggle Software Keyboard` to enable the software keyboard in your simulator. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.KeyboardLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use KeyboardStylesheet + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter Phone + Enter Number + Enter Number + """ + end + + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Validation + +In this section, we'll focus mainly on using [Ecto Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) to validate data, but know that this is not the only way to validate data if you would like to write your own custom logic in the form event handlers, you absolutely can. + + + +### LiveView Native Changesets Coming Soon! + +LiveView Native Form doesn't currently natively support [Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) and [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) structs the way a traditional [Phoenix.Component.form](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#form/1) does. However there is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/5) to add this behavior so this may change in the near future. As a result, this section is somewhat more verbose than will be necessary in the future, as we have to manually define much of the error handling logic that we expect will no longer be necessary in version `0.3` of LiveView Native. + +To make error handling easier, we've defined an `ErrorUtils` module below that will handle extracting the error message out of a Changeset. This will not be necessary in future versions of LiveView Native, but is a convenient helper for now. + +```elixir +defmodule ErrorUtils do + def error_message(errors, field) do + with {msg, opts} <- errors[field] do + Server.CoreComponents.translate_error({msg, opts}) + else + _ -> "" + end + end +end +``` + +For the sake of context, the `translate_message/2` function handles formatting Ecto Changeset errors. For example, it will inject values such as `count` into the string. + +```elixir +Server.CoreComponents.translate_error( + {"name must be longer than %{count} characters", [count: 10]} +) +``` + +### Changesets + +Here's a `User` changeset we're going to use to validate a `User` struct's `email` field. + +```elixir +defmodule User do + import Ecto.Changeset + defstruct [:email] + @types %{email: :string} + + def changeset(user, params) do + {user, @types} + |> cast(params, [:email]) + |> validate_required([:email]) + |> validate_format(:email, ~r/@/) + end +end +``` + +We're going to define an `error` class so errors will appear red and be left-aligned. + +```elixir +defmodule ErrorStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + """ +end +``` + +Then, we're going to create a LiveView that uses the `User` changeset to validate data. + +Evaluate the example below and view it in your simulator. We've included and `IO.inspect/2` call to view the changeset after submitting the form. Try submitting the form with different values to understand how those values affect the changeset. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.FormValidationLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use ErrorStylesheet + + @impl true + def mount(_params, _session, socket) do + user_changeset = User.changeset(%User{}, %{}) + {:ok, assign(socket, :user_changeset, user_changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter your email + + <%= ErrorUtils.error_message(@user_changeset.errors, :email) %> + + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # Preserve the `:action` field so errors do not vanish. + |> Map.put(:action, socket.assigns.user_changeset.action) + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # faking a Database insert action + |> Map.put(:action, :insert) + # Submit the form and inspect the logs below to view the changeset. + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +In the code above, the `"sumbit"` and `"validate"` events update the changeset based on the current form params. This fills the `errors` field used by the `ErrorUtils` module to format the error message. + +After submitting the form, the `:action` field of the changeset has a value of `:insert`, so the red Text appears using the `:if` conditional display logic. + +In the future, this complexity will likely be handled by the `live_view_native_form` library, but for now this example exists to show you how to write your own error handling based on changesets if needed. + + + +### Empty Fields Send `"null"`. + +If you submit a form with empty fields, those fields may currently send `"null"`. There is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/6) to fix this bug, but it may affect your form behavior for now and require a temporary workaround until the issue is fixed. + +## Mini Project: User Form + +Taking everything you've learned, you're going to create a more complex user form with data validation and error displaying. We've defined a `FormStylesheet` you can use (and modify) if you would like to style your form. + +```elixir +defmodule FormStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +### User Changeset + +First, create a `CustomUser` changeset below that handles data validation. + +**Requirements** + +* A user should have a `name` field +* A user should have a `password` string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn't have a database. In real-world apps passwords should **never** be stored as a simple string, they should be encrypted. +* A user should have an `age` number field greater than `0` and less than `200`. +* A user should have an `email` field which matches an email format (including `@` is sufficient). +* A user should have a `accepted_terms` field which must be true. +* A user should have a `birthdate` field which is a date. +* All fields should be required + +### Example Solution + +```elixir +defmodule CustomUser do + import Ecto.Changeset + defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate] + + @types %{ + name: :string, + password: :string, + age: :integer, + email: :string, + accepted_terms: :boolean, + birthdate: :date + } + + def changeset(user, params) do + {user, @types} + |> cast(params, Map.keys(@types)) + |> validate_required(Map.keys(@types)) + |> validate_length(:password, min: 10) + |> validate_number(:age, greater_than: 0, less_than: 200) + |> validate_acceptance(:accepted_terms) + end + + def error_message(changeset, field) do + with {msg, _reason} <- changeset.errors[field] do + msg + else + _ -> "" + end + end +end +``` + + + +```elixir +defmodule CustomUser do + # define the struct keys + defstruct [] + + # define the types + @types %{} + + def changeset(user, params) do + # Enter your solution + end +end +``` + +### LiveView + +Next, create the `CustomUserFormLive` Live View that lets the user enter their information and displays errors for invalid information upon form submission. + +**Requirements** + +* The `name` field should be a `TextField`. +* The `email` field should be a `TextField`. +* The `password` field should be a `SecureField`. +* The `age` field should be a `TextField` with a `.numberPad` keyboard or a `Slider`. +* The `accepted_terms` field should be a `Toggle`. +* The `birthdate` field should be a `DatePicker`. + +### Example Solution + +```elixir +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + changeset = CustomUser.changeset(%CustomUser{}, %{}) + + {:ok, assign(socket, :changeset, changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + name... + <.form_error changeset={@changeset} field={:name}/> + + email... + <.form_error changeset={@changeset} field={:email}/> + + age... + <.form_error changeset={@changeset} field={:age}/> + + password... + <.form_error changeset={@changeset} field={:password}/> + + Accept the Terms and Conditions: + <.form_error changeset={@changeset} field={:accepted_terms}/> + + Birthday: + <.form_error changeset={@changeset} field={:birthdate}/> + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, socket.assigns.changeset.action) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + # While not strictly required, the form_error component reduces code bloat. + def form_error(assigns) do + ~SWIFTUI""" + + <%= CustomUser.error_message(@changeset, @field) %> + + """ + end +end +``` + + + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + # Remember to provide the initial changeset + {:ok, socket} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + @impl true + # Write your `"validate"` event handler + def handle_event("validate", params, socket) do + {:noreply, socket} + end + + # Write your `"submit"` event handler + def handle_event("submit", params, socket) do + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/ex_doc_notebooks/getting-started.md b/guides/ex_doc_notebooks/getting-started.md new file mode 100644 index 000000000..63e640e5e --- /dev/null +++ b/guides/ex_doc_notebooks/getting-started.md @@ -0,0 +1,87 @@ +# Getting Started + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%getting-started.livemd) + +## Overview + +Our livebook guides provide step-by-step lessons to help you learn LiveView Native using Livebook. These guides assume that you already have some familiarity with Phoenix LiveView applications. + +You can read these guides online, or for the best experience we recommend you click on the "Run in Livebook" badge to import and run these guides locally with Livebook. + +Each guide can be completed independently, but we suggest following them chronologically for the most comprehensive learning experience. + +## Prerequisites + +To use these guides, you'll need to install the following prerequisites: + +* [Elixir/Erlang](https://elixir-lang.org/install.html) +* [Livebook](https://livebook.dev/) +* [Xcode](https://developer.apple.com/xcode/) + +While not necessary for our guides, we also recommend you install the following for general LiveView Native development: + +* [Phoenix](https://hexdocs.pm/phoenix/installation.html) +* [PostgreSQL](https://www.postgresql.org/download/) +* [LiveView Native VS Code Extension](https://github.com/liveview-native/liveview-native-vscode) + +## Hello World + +If you are not already running this guide in Livebook, click on the "Run in Livebook" badge at the top of this page to import this guide into Livebook. + +Then, you can evaluate the following smart cell and visit http://localhost:4000 to ensure this Livebook works correctly. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +In an upcoming lesson, you'll set up an iOS application with Xcode so you can run code native examples. + +## Your Turn: Live Reloading + +Change `Hello from LiveView!` to `Hello again from LiveView!` in the above LiveView. Re-evaluate the cell and notice the application live reloads and automatically updates in the browser. + +## Kino LiveView Native + +To run a Phoenix Server setup with LiveView Native from within Livebook we built the [Kino LiveView Native](https://github.com/liveview-native/kino_live_view_native) library. + +Whenever you run one of our Livebooks, a server starts on localhost:4000. Ensure you have no other servers running on port 4000 + +Kino LiveView Native defines the **LiveView Native: LiveView** and **LiveViewNative: Render Component** smart cells within these guides. + +## Troubleshooting + +Some common issues you may encounter are: + +* Another server is already running on port 4000. +* Your version of Livebook needs to be updated. +* Your version of Elixir/Erlang needs to be updated. +* Your version of Xcode needs to be updated. +* This Livebook has cached outdated versions of dependencies + +Ensure you have the latest versions of all necessary software installed, and ensure no other servers are running on port 4000. + +To clear the cache, you can click the `Setup without cache` button revealed by clicking the dropdown next to the `setup` button at the top of the Livebook. + +If that does not resolve the issue, you can [raise an issue](https://github.com/liveview-native/live_view_native/issues/new) to receive support from the LiveView Native team. diff --git a/guides/ex_doc_notebooks/interactive-swiftui-views.md b/guides/ex_doc_notebooks/interactive-swiftui-views.md new file mode 100644 index 000000000..4f5ddc2bb --- /dev/null +++ b/guides/ex_doc_notebooks/interactive-swiftui-views.md @@ -0,0 +1,756 @@ +# Interactive SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%interactive-swiftui-views.livemd) + +## Overview + +In this guide, you'll learn how to build interactive LiveView Native applications using event bindings. + +This guide assumes some existing familiarity with [Phoenix Bindings](https://hexdocs.pm/phoenix_live_view/bindings.html) and how to set/access state stored in the LiveView's socket assigns. To get the most out of this material, you should already understand the `assign/3`/`assign/2` function, and how event bindings such as `phx-click` interact with the `handle_event/3` callback function. + +We'll use the following LiveView and define new render component examples throughout the guide. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Event Bindings + +We can bind any available `phx-*` [Phoenix Binding](https://hexdocs.pm/phoenix_live_view/bindings.html) to a SwiftUI Element. However certain events are not available on native. + +LiveView Native currently supports the following events on all SwiftUI views: + +* `phx-window-focus`: Fired when the application window gains focus, indicating user interaction with the Native app. +* `phx-window-blur`: Fired when the application window loses focus, indicating the user's switch to other apps or screens. +* `phx-focus`: Fired when a specific native UI element gains focus, often used for input fields. +* `phx-blur`: Fired when a specific native UI element loses focus, commonly used with input fields. +* `phx-click`: Fired when a user taps on a native UI element, enabling a response to tap events. + +> The above events work on all SwiftUI views. Some events are only available on specific views. For example, `phx-change` is available on controls and `phx-throttle/phx-debounce` is available on views with events. + +There is also a [Pull Request](https://github.com/liveview-native/liveview-client-swiftui/issues/1095) to add Key Events which may have been merged since this guide was published. + +## Basic Click Example + +The `phx-click` event triggers a corresponding [handle_event/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_event/3) callback function whenever a SwiftUI view is pressed. + +In the example below, the client sends a `"ping"` event to the server, and trigger's the LiveView's `"ping"` event handler. + +Evaluate the example below, then click the `"Click me!"` button. Notice `"Pong"` printed in the server logs below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("ping", _params, socket) do + IO.puts("Pong") + {:noreply, socket} + end +end +``` + +### Click Events Updating State + +Event handlers in LiveView can update the LiveView's state in the socket. + +Evaluate the cell below to see an example of incrementing a count. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end +end +``` + +### Your Turn: Decrement Counter + +You're going to take the example above, and create a counter that can **both increment and decrement**. + +There should be two buttons, each with a `phx-click` binding. One button should bind the `"decrement"` event, and the other button should bind the `"increment"` event. Each event should have a corresponding handler defined using the `handle_event/3` callback function. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("decrement", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Selectable Lists + +`List` views support selecting items within the list based on their id. To select an item, provide the `selection` attribute with the item's id. + +Pressing a child item in the `List` on a native device triggers the `phx-change` event. In the example below we've bound the `phx-change` event to send the `"selection-changed"` event. This event is then handled by the `handle_event/3` callback function and used to change the selected item. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Item <%= i %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, selection: "None")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + +## Expandable Lists + +`List` views support hierarchical content using the [DisclosureGroup](https://developer.apple.com/documentation/swiftui/disclosuregroup) view. Nest `DisclosureGroup` views within a list to create multiple levels of content as seen in the example below. + +To control a `DisclosureGroup` view, use the `is-expanded` boolean attribute as seen in the example below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + Item 2 + Item 3 + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :is_expanded, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-expanded" => is_expanded}, socket) do + {:noreply, assign(socket, is_expanded: !is_expanded)} + end +end +``` + +### Multiple Expandable Lists + +The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + + Level 2 + Item 2 + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle-" <> level, %{"is-expanded" => is_expanded}, socket) do + level = String.to_integer(level) + + {:noreply, + assign( + socket, + :expanded_groups, + Map.replace!(socket.assigns.expanded_groups, level, !is_expanded) + )} + end +end +``` + +## Controls and Indicators + +In Phoenix, the `phx-change` event must be applied to a parent form. However in SwiftUI there is no similar concept of forms. Instead, SwiftUI provides [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) views. We can apply the `phx-change` binding to any of these views. + +Once bound, the SwiftUI view will send a message to the LiveView anytime the control or indicator changes its value. + +The params of the message are based on the name of the [Binding](https://developer.apple.com/documentation/swiftui/binding) argument of the view's initializer in SwiftUI. + + + +### Event Value Bindings + +Many views use the `value` binding argument, so event params are generally sent as `%{"value" => value}`. However, certain views such as `TextField` and `Toggle` deviate from this pattern because SwiftUI uses a different `value` binding argument. For example, the `TextField` view uses `text` to bind its value, so it sends the event params as `%{"text" => value}`. + +When in doubt, you can connect the event handler and inspect the params to confirm the shape of map. + +## Text Field + +The following example shows you how to connect a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) with a `phx-change` event binding to a corresponding event handler. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the server logs in the console below as a map of `%{"text" => value}`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", params, socket) do + IO.inspect(params, label: "params") + {:noreply, socket} + end +end +``` + +### Storing TextField Values in the Socket + +The following example demonstrates how to set/access a TextField's value by controlling it using the socket assigns. + +This pattern is useful when rendering the TextField's value elsewhere on the page, using the `TextField` view's value in other event handler logic, or to set an initial value. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + + The current value: <%= @text %> + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :text, "initial value")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", %{"text" => text}, socket) do + {:noreply, assign(socket, :text, text)} + end + + @impl true + def handle_event("pretty-print", _params, socket) do + IO.puts(""" + ================== + #{socket.assigns.text} + ================== + """) + + {:noreply, socket} + end +end +``` + +## Slider + +This code example renders a SwiftUI [Slider](https://developer.apple.com/documentation/swiftui/slider). It triggers the change event when the slider is moved and sends a `"slide"` message. The `"slide"` event handler then logs the value to the console. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the console below as a map of `%{"value" => value}`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Percent Completed + 0% + 100% + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("slide", params, socket) do + IO.inspect(params, label: "Slide Params") + {:noreply, socket} + end +end +``` + +## Stepper + +This code example renders a SwiftUI [Stepper](https://developer.apple.com/documentation/swiftui/stepper). It triggers the change event and sends a `"change-tickets"` message when the stepper increments or decrements. The `"change-tickets"` event handler then updates the number of tickets stored in state, which appears in the UI. + +Evaluate the example and increment/decrement the step. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Tickets <%= @tickets %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :tickets, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change-tickets", %{"value" => tickets}, socket) do + {:noreply, assign(socket, :tickets, tickets)} + end +end +``` + +## Toggle + +This code example renders a SwiftUI [Toggle](https://developer.apple.com/documentation/swiftui/toggle). It triggers the change event and sends a `"toggle"` message when toggled. The `"toggle"` event handler then updates the `:on` field in state, which allows the `Toggle` view to be toggled on. Without providing the `is-on` attribute, the `Toggle` view could not be flipped on and off. + +Evaluate the example below and click on the toggle. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + On/Off + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :on, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-on" => on}, socket) do + {:noreply, assign(socket, :on, on)} + end +end +``` + +## DatePicker + +The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :date, nil)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", params, socket) do + IO.inspect(params, label: "Date Params") + {:noreply, socket} + end +end +``` + +### Parsing Dates + +The date from the `DatePicker` is in iso8601 format. You can use the `from_iso8601` function to parse this string into a `DateTime` struct. + +```elixir +iso8601 = "2024-01-17T20:51:00.000Z" + +DateTime.from_iso8601(iso8601) +``` + +### Your Turn: Displayed Components + +The `DatePicker` view accepts a `displayed-components` attribute with the value of `"hour-and-minute"` or `"date"` to only display one of the two components. By default, the value is `"all"`. + +You're going to change the `displayed-components` attribute in the example below to see both of these options. Change `"all"` to `"date"`, then to `"hour-and-minute"`. Re-evaluate the cell between changes and see the updated UI. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + def handle_event("pick-date", params, socket) do + {:noreply, socket} + end +end +``` + +## Small Project: Todo List + +Using the previous examples as inspiration, you're going to create a todo list. + +**Requirements** + +* Items should be `Text` views rendered within a `List` view. +* Item ids should be stored in state as a list of integers i.e. `[1, 2, 3, 4]` +* Use a `TextField` to provide the name of the next added todo item. +* An add item `Button` should add items to the list of integers in state when pressed. +* A delete item `Button` should remove the currently selected item from the list of integers in state when pressed. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Todo... + + + + <%= content %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type-name", %{"text" => name}, socket) do + {:noreply, assign(socket, :item_name, name)} + end + + def handle_event("add-item", _params, socket) do + updated_items = [ + {"item-#{socket.assigns.next_item_id}", socket.assigns.item_name} + | socket.assigns.items + ] + + {:noreply, + assign(socket, + item_name: "", + items: updated_items, + next_item_id: socket.assigns.next_item_id + 1 + )} + end + + def handle_event("delete-item", _params, socket) do + updated_items = + Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end) + {:noreply, assign(socket, :items, updated_items)} + end + + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + # Define your mount/3 callback + + @impl true + def render(assigns), do: ~H"" + + # Define your render/3 callback + + # Define any handle_event/3 callbacks +end +``` diff --git a/guides/ex_doc_notebooks/native-navigation.md b/guides/ex_doc_notebooks/native-navigation.md new file mode 100644 index 000000000..e29631de7 --- /dev/null +++ b/guides/ex_doc_notebooks/native-navigation.md @@ -0,0 +1,303 @@ +# Native Navigation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%native-navigation.livemd) + +## Overview + +This guide will teach you how to create multi-page applications using LiveView Native. We will cover navigation patterns specific to native applications and how to reuse the existing navigation patterns available in LiveView. + +Before diving in, you should have a basic understanding of navigation in LiveView. You should be familiar with the [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions, which are used to trigger navigation from within a LiveView. Additionally, you should know how to define routes in the router using the [live/4](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4) macro. + +## NavigationStack + +LiveView Native applications are generally wrapped in a [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view. This view usually exists in the `root.swiftui.heex` file, which looks something like the following: + + + +```elixir +<.csrf_token /> + + +
+ Hello, from LiveView Native! +
+
+``` + +Notice the [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view wraps the template. This view manages the state of navigation history and allows for navigating back to previous pages. + +## Navigation Links + +We can use the [NavigationLink](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/navigationlink) view for native navigation, similar to how we can use the [.link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with the `navigate` attribute for web navigation. + +We've created the same example of navigating between the `Main` and `About` pages. Each page using a `NavigationLink` to navigate to the other page. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + To Home + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + + + +```elixir +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the main page + + To About + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +The `destination` attribute works the same as the `navigate` attribute on the web. The current LiveView will shut down, and a new one will mount without re-establishing a new socket connection. + +## Push Navigation + +For LiveView Native views, we can still use the same [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2), and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions used in typical LiveViews. + +These functions are preferable over `NavigationLink` views when you want to share navigation handlers between web and native, and/or when you want to have more customized navigation handling. + +Evaluate **both** of the code cells below and click on the `Button` view in your simulator that triggers the `handle_event/3` navigation handler to navigate between the two views. + + + +```elixir +defmodule ServerWeb.MainLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the Main Page + + """ + end +end + +defmodule ServerWeb.MainLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-about", _params, socket) do + {:noreply, push_navigate(socket, to: "/about")} + end +end +``` + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the About Page + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-main", _params, socket) do + {:noreply, push_navigate(socket, to: "/")} + end +end +``` + +## Routing + +The `KinoLiveViewNative` smart cells used in this guide automatically define routes for us. Be aware there is no difference between how we define routes for LiveView or LiveView Native. + +The routes for the main and about pages might look like the following in the router: + + + +```elixir +live "/", Server.MainLive +live "/about", Server.AboutLive +``` + +## Native Navigation Events + +LiveView Native navigation mirrors the same navigation behavior you'll find on the web. + +Evaluate the example below and press each button. Notice that: + +1. `redirect/2` triggers the `mount/3` callback re-establishes a socket connection. +2. `push_navigate/2` triggers the `mount/3` callbcak and re-uses the existing socket connection. +3. `push_patch/2` does not trigger the `mount/3` callback, but does trigger the `handle_params/3` callback. This is often useful when using navigation to trigger page changes such as displaying a modal or overlay. + +You can see this for yourself using the following example. Click each of the buttons for redirect, navigate, and patch behavior. + + + +```elixir +# This module built for example purposes to persist logs between mounting LiveViews. +defmodule PersistantLogs do + def get do + :persistent_term.get(:logs) + end + + def put(log) when is_binary(log) do + :persistent_term.put(:logs, [{log, Time.utc_now()} | get()]) + end + + def reset do + :persistent_term.put(:logs, []) + end +end + +PersistantLogs.reset() + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + + + Socket ID<%= @socket_id %> + LiveView PID:<%= @live_view_pid %> + <%= for {log, time} <- Enum.reverse(@logs) do %> + + <%= Calendar.strftime(time, "%H:%M:%S") %>: + <%= log %> + + <% end %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + PersistantLogs.put("MOUNT") + + {:ok, + assign(socket, + socket_id: socket.id, + connected: connected?(socket), + logs: PersistantLogs.get(), + live_view_pid: inspect(self()) + )} + end + + @impl true + def handle_params(_params, _url, socket) do + PersistantLogs.put("HANDLE PARAMS") + + {:noreply, assign(socket, :logs, PersistantLogs.get())} + end + + @impl true + def render(assigns), + do: ~H"" + + @impl true + def handle_event("redirect", _params, socket) do + PersistantLogs.reset() + PersistantLogs.put("--REDIRECTING--") + {:noreply, redirect(socket, to: "/")} + end + + def handle_event("navigate", _params, socket) do + PersistantLogs.put("---NAVIGATING---") + {:noreply, push_navigate(socket, to: "/")} + end + + def handle_event("patch", _params, socket) do + PersistantLogs.put("----PATCHING----") + {:noreply, push_patch(socket, to: "/")} + end +end +``` diff --git a/guides/ex_doc_notebooks/stylesheets.md b/guides/ex_doc_notebooks/stylesheets.md new file mode 100644 index 000000000..979af24f3 --- /dev/null +++ b/guides/ex_doc_notebooks/stylesheets.md @@ -0,0 +1,538 @@ +# Stylesheets + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%stylesheets.livemd) + +## Overview + +In this guide, you'll learn how to use stylesheets to customize the appearance of your LiveView Native Views. You'll also learn about the inner workings of how LiveView Native uses stylesheets to implement modifiers, and how those modifiers style and customize SwiftUI Views. By the end of this lesson, you'll have the fundamentals you need to create beautiful native UIs. + +## The Stylesheet AST + +LiveView Native parses through your application at compile time to create a stylesheet AST representation of all the styles in your application. This stylesheet AST is used by the LiveView Native Client application when rendering the view hierarchy to apply modifiers to a given view. + +```mermaid +sequenceDiagram + LiveView->>LiveView: Create stylesheet + Client->>LiveView: Send request to "http://localhost:4000/?_format=swiftui" + LiveView->>Client: Send LiveView Native template in response + Client->>LiveView: Send request to "http://localhost:4000/assets/app.swiftui.styles" + LiveView->>Client: Send stylesheet in response + Client->>Client: Parses stylesheet into SwiftUI modifiers + Client->>Client: Apply modifiers to the view hierarchy +``` + +We've setup this Livebook to be included when parsing the application for modifiers. You can visit http://localhost:4000/assets/app.swiftui.styles to see the Stylesheet AST created by all of the styles in this Livebook and any other styles used in the `kino_live_view_native` project. + +LiveView Native watches for changes and updates the stylesheet, so those will be dynamically picked up and applied, You may notice a slight delay as the Livebook takes **5 seconds** to write it's contents to a file. + +## Modifiers + +SwiftUI employs **modifiers** to style and customize views. In SwiftUI syntax, each modifier is a function that can be chained onto the view they modify. LiveView Native has a minimal DSL (Domain Specific Language) for writing SwiftUI modifiers. + +Modifers can be applied through a LiveView Native Stylesheet and applying them through classes as described in the [LiveView Native Stylesheets](#liveview-native-stylesheets) section, or can be applied directly through the `class` attribute as described in the [Utility Styles](#utility-styles) section. + + + +### SwiftUI Modifiers + +Here's a basic example of making text red using the [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) modifier: + +```swift +Text("Some Red Text") + .foregroundStyle(.red) +``` + +Many modifiers can be applied to a view. Here's an example using [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) and [frame](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)). + +```swift +Text("Some Red Text") + .foregroundStyle(.red) + .font(.title) +``` + + + +### Implicit Member Expression + +Implicit Member Expression in SwiftUI means that we can implicityly access a member of a given type without explicitly specifying the type itself. For example, the `.red` value above is from the [Color](https://developer.apple.com/documentation/swiftui/color) structure. + +```swift +Text("Some Red Text") + .foregroundStyle(Color.red) +``` + + + +### LiveView Native Modifiers + +The DSL (Domain Specific Language) used in LiveView Native drops the `.` dot before each modifier, but otherwise remains largely the same. We do not document every modifier separately, since you can translate SwiftUI examples into the DSL syntax. + +For example, Here's the same `foregroundStyle` modifier as it would be written in a LiveView Native stylesheet or class attribute, which we'll cover in a moment. + +```swift +foregroundStyle(.red) +``` + +There are some exceptions where the DSL differs from SwiftUI syntax, which we'll cover in the sections below. + +## Utility Styles + +In addition to introducing stylesheets, LiveView Native `0.3.0` also introduced Utility classes, which will be our prefered method for writing styles in these Livebook guides. + +The same SwiftUI syntax used inside of a stylesheet can be used directly inside of a `class` attribute. The example below defines the `foregroundStyle(.red)` modifier. Evaluate the example and view it in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Multiple Modifiers + +You can write multiple modifiers, separate each by a space or newline character. + +```html +Hello, from LiveView Native! +``` + +For newline characters, you'll need to wrap the string in curly brackets `{}`. Using multiple lines can better organize larger amounts of modifiers. + +```html + +Hello, from LiveView Native! + +``` + +## Dynamic Class Names + +LiveView Native parses styles in your project to define a single stylesheet. You can find the AST representation of this stylesheet at http://localhost:4000/assets/app.swiftui.styles. This stylesheet is compiled on the server and then sent to the client. For this reason, class names must be fully-formed. For example, the following class using string interpolation is **invalid**. + +```html + +Invalid Example + +``` + +However, we can still use dynamic styles so long as the class names are fully formed. + +```html + +Red or Blue Text + +``` + +Evaluate the example below multiple times while watching your simulator. Notice that the text is dynamically red or blue. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Modifier Order + +Modifier order matters. Changing the order that modifers are applied can have a significant impact on their behavior. + +To demonstrate this concept, we're going to take a simple example of applying padding and background color. + +If we apply the background color first, then the padding, The background is applied to original view, leaving the padding filled with whitespace. + + + +```elixir +background(.orange) +padding(20) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style View fill:orange +``` + +If we apply the padding first, then the background, the background is applied to the view with the padding, thus filling the entire area with background color. + + + +```elixir +padding(20) +background(.orange) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style Padding fill:orange +style View fill:orange +``` + +Evaluate the example below to see this in action. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Injecting Views in Stylesheets + +SwiftUI modifiers sometimes accept SwiftUI views as arguments. Here's an example using the `clipShape` modifier with a `Circle` view. + +```swift +Image("logo") + .clipShape(Circle()) +``` + +However, LiveView Native does not support using SwiftUI views directly within a stylesheet. Instead, we have a few alternative options in cases like this where we want to use a view within a modifier. + + + +### Using Members on a Given Type + +We can't use the [Circle](https://developer.apple.com/documentation/swiftui/circle) view directly. However, if you look at the [clipShape](https://developer.apple.com/documentation/swiftui/view/clipshape(_:style:)) documentation you'll notice it accepts the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. This type defines the [circle](https://developer.apple.com/documentation/swiftui/shape/circle) property which we can use since it's equivalent to the [Circle](https://developer.apple.com/documentation/swiftui/circle) view for our purposes. + +We can use `Shape.circle` instead of the `Circle` view. So, the following code is equivalent to the example above. + +```swift +Image("logo") + .clipShape(Shape.circle) +``` + +Using implicit member expression, we can simplify this code to the following: + +```swift +Image("logo") + .clipShape(.circle) +``` + +Which is simple to convert to the LiveView Native DSL using the rules we've already learned. + + + +```elixir +"example-class" do + clipShape(.circle) +end +``` + + + +### Injecting a View + +For more complex cases, we can inject a view directly into a stylesheet. + +Here's an example where this might be useful. SwiftUI has modifers that represent a named content area for views to be placed within. These views can even have their own modifiers, so it's not enough to use a simple static property on the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. + +```swift +Image("logo") + .overlay(content: { + Circle().stroke(.red, lineWidth: 4) + }) +``` + +To get around this issue, we instead inject a view into the stylesheet. First, define the modifier and use an atom to represent the view that's going to be injected. + + + +```elixir +"overlay-circle" do + overlay(content: :circle) +end +``` + +Then use the `template` attribute on the view to be injected into the stylesheet. This view should be a child of the view with the given class. + +```html + + + +``` + +We can then apply modifiers to the child view through a class as we've already seen. + +## Custom Colors + +### SwiftUI Color Struct + +The SwiftUI [Color](https://developer.apple.com/documentation/swiftui/color) structure accepts either the name of a color in the asset catalog or the RGB values of the color. + +Therefore we can define custom RBG styles like so: + +```swift +foregroundStyle(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) +``` + +Evaluate the example below to see the custom color in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Custom Colors in the Asset Catalogue + +Custom colors can be defined in the [Asset Catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Once defined in the asset catalogue of the Xcode application, the color can be referenced by name like so: + +```swift +foregroundStyle(Color("MyColor")) +``` + +Generally using the asset catalog is more performant and customizable than using custom RGB colors with the [Color](https://developer.apple.com/documentation/swiftui/color) struct. + + + +### Your Turn: Custom Colors in the Asset Catalog + +Custom colors can be defined in the asset catalog (https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Generat + +To create a new color go to the `Assets` folder in your iOS app and create a new color set. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-create-new-color-set.png?raw=true) + + + +To create a color set, enter the RGB values or a hexcode as shown in the image below. If you don't see the sidebar with color options, click the icon in the top-right of your Xcode app and click the **Show attributes inspector** icon shown highlighted in blue. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-modify-my-color.png?raw=true) + + + +The defined color is now available for use within LiveView Native styles. However, the app needs to be re-compiled to pick up a new color set. + +Re-build your SwiftUI Application before moving on. Then evaluate the code below. You should see your custom colored text in the simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## LiveView Native Stylesheets + +In LiveView Native, we use `~SHEET` sigil stylesheets to organize modifers by classes using an Elixir-oriented DSL similar to CSS for styling web elements. + +We group modifiers together within a class that can be applied to an element. Here's an example of how modifiers can be grouped into a "red-title" class in a stylesheet: + + + +```elixir +~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end +""" +``` + +We're mostly using Utility styles for these guides, but the stylesheet module does contain some important configuration to `@import` the utility styles module. It can also be used to group styles within a class if you have a set of modifiers you're repeatedly using and want to group together. + + + +```elixir +defmodule ServerWeb.Styles.App.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + @import LiveViewNative.SwiftUI.UtilityStyles + + ~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end + """ +end +``` + +Since the Phoenix server runs in a dependency for these guides, you don't have direct access to the stylesheet module. + +## Apple Documentation + +You can find documentation and examples of modifiers on [Apple's SwiftUI documentation](https://developer.apple.com/documentation/swiftui) which is comprehensive and thorough, though it may feel unfamiliar at first for Elixir Developers when compared to HexDocs. + + + +### Finding Modifiers + +The [Configuring View Elements](https://developer.apple.com/documentation/swiftui/view#configuring-view-elements) section of apple documentation contains links to modifiers organized by category. In that documentation you'll find useful references such as [Style Modifiers](https://developer.apple.com/documentation/swiftui/view-style-modifiers), [Layout Modifiers](https://developer.apple.com/documentation/swiftui/view-layout), and [Input and Event Modifiers](https://developer.apple.com/documentation/swiftui/view-input-and-events). + +You can also find the same modifiers with LiveView Native examples on the [LiveView Client SwiftUI Docs](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/paddingmodifier). + +## Visual Studio Code Extension + +If you use Visual Studio Code, we strongly recommend you install the [LiveView Native Visual Studio Code Extension](https://github.com/liveview-native/liveview-native-vscode) which provides autocompletion and type information thus making modifiers significantly easier to write and lookup. + +## Your Turn: Syntax Conversion + +Part of learning LiveView Native is learning SwiftUI. Fortunately we can leverage the existing SwiftUI ecosystem and convert examples into LiveView Native syntax. + +You're going to convert the following SwiftUI code into a LiveView Native template. This example is inspired by the official [SwiftUI Tutorials](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + +```elixir + VStack { + VStack(alignment: .leading) { + Text("Turtle Rock") + .font(.title) + HStack { + Text("Joshua Tree National Park") + Spacer() + Text("California") + } + .font(.subheadline) + + Divider() + + Text("About Turtle Rock") + .font(.title2) + Text("Descriptive text goes here") + } + .padding() + + Spacer() +} +``` + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Turtle Rock + + Joshua Tree National Park + + California + + + About Turtle Rock + Descriptive text goes here + + """ + end +end +``` + + + +Enter your solution below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end +``` diff --git a/guides/ex_doc_notebooks/swiftui-views.md b/guides/ex_doc_notebooks/swiftui-views.md new file mode 100644 index 000000000..110ceb07b --- /dev/null +++ b/guides/ex_doc_notebooks/swiftui-views.md @@ -0,0 +1,693 @@ +# SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%2Flive_view_native%2Fmain%2Fguides%livebooks%swiftui-views.livemd) + +## Overview + +LiveView Native aims to use minimal SwiftUI code. All patterns for building interactive UIs are the same as LiveView. However, unlike LiveView for the web, LiveView Native uses SwiftUI templates to build the native UI. + +This lesson will teach you how to build SwiftUI templates using common SwiftUI views. We'll cover common uses of each view and give you practical examples you can use to build your own native UIs. This lesson is like a recipe book you can refer back to whenever you need an example of how to use a particular SwiftUI view. In addition, once you understand how to convert these views into the LiveView Native DSL, you should have the tools to convert essentially any SwiftUI View into the LiveView Native DSL. + +## Render Components + +LiveView Native `0.3.0` introduced render components to better encourage isolation of native and web templates and move away from co-location templates within the same LiveView module. + +Render components are namespaced under the main LiveView, and are responsible for defining the `render/1` callback function that returns the native template. + +For example, and `ExampleLive` LiveView module would have an `ExampleLive.SwiftUI` render component module for the native Template. + +This `ExampleLive.SwiftUI` render component may define a `render/1` callback function as seen below. + + + +```elixir +# Render Component +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +# LiveView +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +Throughout this and further material we'll re-define render components you can evaluate and see reflected in your Xcode iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from a LiveView Native Render Component! + """ + end +end +``` + +### Embedding Templates + +Alternatively, you may omit the render callback and instead define a `.neex` (Native + Embedded Elixir) template. + +By default, the module above would look for a template in the `swiftui/example_live*` path relative to the module's location. You can see the `LiveViewNative.Component` documentation for further explanation. + +For the sake of ease when working in Livebook, we'll prefer defining the `render/1` callback. However, we recommend you generally prefer template files when working locally in Phoenix LiveView Native projects. + +## SwiftUI Views + +In SwiftUI, a "View" is like a building block for what you see on your app's screen. It can be something simple like text or an image, or something more complex like a layout with multiple elements. Views are the pieces that make up your app's user interface. + +Here's an example `Text` view that represents a text element. + +```swift +Text("Hamlet") +``` + +LiveView Native uses the following syntax to represent the view above. + + + +```elixir +Hamlet +``` + +SwiftUI provides a wide range of Views that can be used in native templates. You can find a full reference of these views in the SwiftUI Documentation at https://developer.apple.com/documentation/swiftui/. You can also find a shorthand on how to convert SwiftUI syntax into the LiveView Native DLS in the [LiveView Native Syntax Conversion Cheatsheet](https://hexdocs.pm/live_view_native/cheatsheet.cheatmd). + +## Text + +We've already seen the [Text](https://developer.apple.com/documentation/swiftui/text) view, but we'll start simple to get the interactive tutorial running. + +Evaluate the cell below, then in Xcode, Start the iOS application you created in the [Create a SwiftUI Application](https://hexdocs.pm/live_view_native/create-a-swiftui-application.html) lesson and ensure you see the `"Hello, from LiveView Native!"` text. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end +``` + +## HStack and VStack + +SwiftUI includes many [Layout](https://developer.apple.com/documentation/swiftui/layout-fundamentals) container views you can use to arrange your user Interface. Here are a few of the most commonly used: + +* [VStack](https://developer.apple.com/documentation/swiftui/vstack): Vertically arranges nested views. +* [HStack](https://developer.apple.com/documentation/swiftui/hstack): Horizontally arranges nested views. + +Below, we've created a simple 3X3 game board to demonstrate how to use `VStack` and `HStack` to build a layout of horizontal rows in a single vertical column.o + +Here's a diagram to demonstrate how these rows and columns create our desired layout. + +```mermaid +flowchart +subgraph VStack + direction TB + subgraph H1[HStack] + direction LR + 1[O] --> 2[X] --> 3[X] + end + subgraph H2[HStack] + direction LR + 4[X] --> 5[O] --> 6[O] + end + subgraph H3[HStack] + direction LR + 7[X] --> 8[X] --> 9[O] + end + H1 --> H2 --> H3 +end +``` + +Evaluate the example below and view the working 3X3 layout in your Xcode simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +``` + +### Your Turn: 3x3 board using columns + +In the cell below, use `VStack` and `HStack` to create a 3X3 board using 3 columns instead of 3 rows as demonstrated above. The arrangement of `X` and `O` does not matter, however the content will not be properly aligned if you do not have exactly one character in each `Text` element. + +```mermaid +flowchart +subgraph HStack + direction LR + subgraph V1[VStack] + direction TB + 1[O] --> 2[X] --> 3[X] + end + subgraph V2[VStack] + direction TB + 4[X] --> 5[O] --> 6[O] + end + subgraph V3[VStack] + direction TB + 7[X] --> 8[X] --> 9[O] + end + V1 --> V2 --> V3 +end +``` + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Grid + +`VStack` and `HStack` do not provide vertical-alignment between horizontal rows. Notice in the following example that the rows/columns of the 3X3 board are not aligned, just centered. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + X + X + + + X + O + O + + + X + O + + + """ + end +end +``` + +Fortunately, we have a few common elements for creating a grid-based layout. + +* [Grid](https://developer.apple.com/documentation/swiftui/grid): A grid that arranges its child views in rows and columns that you specify. +* [GridRow](https://developer.apple.com/documentation/swiftui/gridrow): A view that arranges its children in a horizontal line. + +A grid layout vertically and horizontally aligns elements in the grid based on the number of elements in each row. + +Evaluate the example below and notice that rows and columns are aligned. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + XX + X + X + + + X + X + + + X + X + X + + + """ + end +end +``` + +## List + +The SwiftUI [List](https://developer.apple.com/documentation/swiftui/list) view provides a system-specific interface, and has better performance for large amounts of scrolling elements. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + Item 1 + Item 2 + Item 3 + + """ + end +end +``` + +### Multi-dimensional lists + +Alternatively we can separate children within a `List` view in a `Section` view as seen in the example below. Views in the `Section` can have the `template` attribute with a `"header"` or `"footer"` value which controls how the content is displayed above or below the section. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + +
+ Header + Content + Footer +
+
+ """ + end +end +``` + +## ScrollView + +The SwiftUI [ScrollView](https://developer.apple.com/documentation/swiftui/scrollview) displays content within a scrollable region. ScrollView is often used in combination with [LazyHStack](https://developer.apple.com/documentation/swiftui/lazyvstack), [LazyVStack](https://developer.apple.com/documentation/swiftui/lazyhstack), [LazyHGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid), and [LazyVGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid) to create scrollable layouts optimized for displaying large amounts of data. + +While `ScrollView` also works with typical `VStack` and `HStack` views, they are not optimal choices for large amounts of data. + + + +### ScrollView with VStack + +Here's an example using a `ScrollView` and a `HStack` to create scrollable text arranged horizontally. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +### ScrollView with HStack + +By default, the [axes](https://developer.apple.com/documentation/swiftui/scrollview/axes) of a `ScrollView` is vertical. To make a horizontal `ScrollView`, set the `axes` attribute to `"horizontal"` as seen in the example below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +### Optimized ScrollView with LazyHStack and LazyVStack + +`VStack` and `HStack` are inefficient for large amounts of data because they render every child view. To demonstrate this, evaluate the example below. You should experience lag when you attempt to scroll. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +To resolve the performance problem for large amounts of data, you can use the Lazy views. Lazy views only create items as needed. Items won't be rendered until they are present on the screen. + +The next example demonstrates how using `LazyVStack` instead of `VStack` resolves the performance issue. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +## Spacers + +[Spacers](https://developer.apple.com/documentation/swiftui/spacer) take up all remaining space in a container. + +![Apple Documentation](https://docs-assets.developer.apple.com/published/189fa436f07ed0011bd0c1abeb167723/Building-Layouts-with-Stack-Views-4@2x.png) + +> Image originally from https://developer.apple.com/documentation/swiftui/spacer + +Evaluate the following example and notice the `Text` element is pushed to the right by the `Spacer`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + This text is pushed to the right + + """ + end +end +``` + +### Your Turn: Bottom Text Spacer + +In the cell below, use `VStack` and `Spacer` to place text in the bottom of the native view. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Hello + + """ + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## AsyncImage + +`AsyncImage` is best for network images, or images served by the Phoenix server. + +Here's an example of `AsyncImage` with a lorem picsum image from https://picsum.photos/400/600. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Loading Spinner + +`AsyncImage` displays a loading spinner while loading the image. Here's an example of using `AsyncImage` without a URL so that it loads forever. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Relative Path + +For images served by the Phoenix server, LiveView Native evaluates URLs relative to the LiveView's host URL. This way you can use the path to static resources as you normally would in a Phoenix application. + +For example, the path `/images/logo.png` evaluates as http://localhost:4000/images/logo.png below. This serves the LiveView Native logo. + +Evaluate the example below to see the LiveView Native logo in the iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Image + +The `Image` element is best for system images such as the built in [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols) or images placed into the SwiftUI [asset catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). + + + +### System Images + +You can use the `system-image` attribute to provide the name of system images to the `Image` element. + +For the full list of SF Symbols you can download Apple's [Symbols 5](https://developer.apple.com/sf-symbols/) application. + +Evaluate the cell below to see an example using the `square.and.arrow.up` symbol. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Your Turn: Asset Catalogue + +You can place assets in your SwiftUI application's asset catalogue. Using the asset catalogue for SwiftUI assets provide many benefits such as device-specific image variants, dark mode images, high contrast image mode, and improved performance. + +Follow this guide: https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs#Add-a-new-asset to create a new asset called Image. + +Then evaluate the following example and you should see this image in your simulator. For a convenient image, you can right-click and save the following LiveView Native logo. + +![LiveView Native Logo](https://github.com/liveview-native/documentation_assets/blob/main/logo.png?raw=true) + +You will need to **rebuild the native application** to pick up the changes to the assets catalogue. + + + +### Enter Your Solution Below + +You should not need to make changes to this cell. Set up an image in your asset catalogue named "Image", rebuild your native application, then evaluate this cell. You should see the image in your iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Button + +A Button is a clickable SwiftUI View. + +The label of a button can be any view, such as a [Text](https://developer.apple.com/documentation/swiftui/text) view for text-only buttons or a [Label](https://developer.apple.com/documentation/swiftui/label) view for buttons with icons. + +Evaluate the example below to see the SwiftUI [Button](https://developer.apple.com/documentation/swiftui/button) element. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + """ + end +end +``` + +## Further Resources + +See the [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui) for a complete list of SwiftUI elements and the [LiveView Native SwiftUI Documentation](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/) for LiveView Native examples of the SwiftUI elements. diff --git a/guides/livebooks/create-a-swiftui-application.livemd b/guides/livebooks/create-a-swiftui-application.livemd new file mode 100644 index 000000000..06a322508 --- /dev/null +++ b/guides/livebooks/create-a-swiftui-application.livemd @@ -0,0 +1,276 @@ + + +# Create a SwiftUI Application + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +This guide will teach you how to set up a SwiftUI Application for LiveView Native. + +Typically, we recommend using the `mix lvn.install` task as described in the [Installation Guide](https://hexdocs.pm/live_view_native/installation.html#5-enable-liveview-native) to add LiveView Native to a Phoenix project. However, we will walk through the steps of manually setting up an Xcode iOS project to learn how the iOS side of a LiveView Native application works. + +In future lessons, you'll use this iOS application to view iOS examples in the Xcode simulator (or a physical device if you prefer.) + +## Prerequisites + +First, make sure you have followed the [Getting Started](https://hexdocs.pm/live_view_native/getting_started.md) guide. Then evaluate the smart cell below and visit http://localhost:4000 to ensure the Phoenix server runs properly. You should see the text `Hello from LiveView!` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Create the iOS Application + +Open Xcode and select Create New Project. + + + +![Xcode Create New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-new-project.png?raw=true) + + + +Select the `iOS` and `App` options to create an iOS application. Then click `Next`. + + + +![Xcode Create Template For New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-template-for-new-project.png?raw=true) + + + +Choose options for your new project that match the following image, then click `Next`. + +
+What do these options mean? + +* **Product Name:** The name of the application. This can be any valid name. We've chosen `Guides`. +* **Organization Identifier:** A reverse DNS string that uniquely identifies your organization. If you don't have a company identifier, [Apple recomends](https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app) using `com.example.your_name` where `your_name` is your organization or personal name. +* **Interface:**: The Xcode user interface to use. Select **SwiftUI** to create an app that uses the SwiftUI app lifecycle. +* **Language:** Determines which language Xcode should use for the project. Select `Swift`. +
+ + + +![Xcode Choose Options For Your New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-choose-options-for-your-new-project.png?raw=true) + + + +Select an appropriate folder location where you would like to store the iOS project, then click `Create`. + + + +![Xcode select folder location](https://github.com/liveview-native/documentation_assets/blob/main/xcode-select-folder-location.png?raw=true) + + + +You should see the default iOS application generated by Xcode. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/default-xcode-app.png?raw=true) + +## Add the LiveView Client SwiftUI Package + +In Xcode from the project you just created, select `File -> Add Package Dependencies`. Then, search for `liveview-client-swiftui`. Once you have selected the package, click `Add Package`. + +The image below was created using version `0.2.0`. You should select whichever is the latest version of LiveView Native. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/add-liveview-swiftui-client-package-0.2.0.png?raw=true) + + + +Choose the Package Products for `liveview-client-swiftui`. Select `Guides` as the target for `LiveViewNative` and `LiveViewNativeStylesheet`. This adds both of these dependencies to your iOS project. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/select-package-products.png?raw=true) + + + +At this point, you'll need to enable permissions for plugins used by LiveView Native. +You should see the following prompt. Click `Trust & Enable All`. + + + +![Xcode some build plugins are disabled](https://github.com/liveview-native/documentation_assets/blob/main/xcode-some-build-plugins-are-disabled.png?raw=true) + + + +You'll also need to manually navigate to the error tab (shown below) and manually trust and enable packages. Click on each error to trigger a prompt. Select `Trust & Enable All` to enable the plugin. + +The specific plugins are subject to change. At the time of writing you need to enable `LiveViewNativeStylesheetMacros`, `LiveViewNativeMacros`, and `CasePathMacros` as shown in the images below. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-stylesheet.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-macros.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-case-path-macros.png?raw=true) + +## Setup the SwiftUI LiveView + +The [ContentView](https://developer.apple.com/tutorials/swiftui-concepts/exploring-the-structure-of-a-swiftui-app#Content-view) contains the main view of our iOS application. + +Replace the code in the `ContentView` file with the following to connect the SwiftUI application and the Phoenix application. + + + +```swift +import SwiftUI +import LiveViewNative + +struct ContentView: View { + + var body: some View { + LiveView(.automatic( + development: .localhost(path: "/"), + production: .custom(URL(string: "https://example.com/")!) + )) + } +} + + +// Optionally preview the native UI in Xcode +#Preview { + ContentView() +} +``` + + + +The code above sets up the SwiftUI LiveView. By default, the SwiftUI LiveView connects to any Phoenix app running on http://localhost:4000. + + + + + +```mermaid +graph LR; + subgraph I[iOS App] + direction TB + ContentView + SL[SwiftUI LiveView] + end + subgraph P[Phoenix App] + LiveView + end + SL --> P + ContentView --> SL + + +``` + +## Start the Active Scheme + +Click the `start active scheme` button to build the project and run it on the iOS simulator. + +> A [build scheme](https://developer.apple.com/documentation/xcode/build-system) contains a list of targets to build, and any configuration and environment details that affect the selected action. For example, when you build and run an app, the scheme tells Xcode what launch arguments to pass to the app. +> +> * https://developer.apple.com/documentation/xcode/build-system + +After you start the active scheme, the simulator should open the iOS application and display `Hello from LiveView Native!`. If you encounter any issues see the **Troubleshooting** section below. + + + +
+ +
+ +## Troubleshooting + +If you encountered any issues with the native application, here are some troubleshooting steps you can use: + +* **Reset Package Caches:** In the Xcode application go to `File -> Packages -> Reset Package Caches`. +* **Update Packages:** In the Xcode application go to `File -> Packages -> Update to Latest Package Versions`. +* **Rebuild the Active Scheme**: In the Xcode application, press the `start active scheme` button to rebuild the active scheme and run it on the Xcode simulator. +* Update your [Xcode](https://developer.apple.com/xcode/) version if it is not already the latest version +* Check for error messages in the Livebook smart cells. + +You can also [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) if you would like support from the LiveView Native team. diff --git a/guides/livebooks/forms-and-validation.livemd b/guides/livebooks/forms-and-validation.livemd new file mode 100644 index 000000000..415ed98dc --- /dev/null +++ b/guides/livebooks/forms-and-validation.livemd @@ -0,0 +1,661 @@ +# Forms and Validation + +```elixir +Mix.install( + [ + {:kino_live_view_native, "0.2.1"}, + {:ecto, "~> 3.11"} + ], + config: [ + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [parsers: [swiftui: LiveViewNative.SwiftUI.RulesParser]], + phoenix_template: [ + format_encoders: [ + swiftui: Phoenix.HTML.Engine + ] + ] + ] +) + +KinoLiveViewNative.start([]) +``` + +## Overview + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) project makes it easier to build forms in LiveView Native. This project enables you to group different [Control Views](https://developer.apple.com/documentation/swiftui/controls-and-indicators) inside of a `LiveForm` and control them collectively under a single `phx-change` or `phx-submit` event handler, rather than with multiple different `phx-change` event handlers. + +Getting the most out of this material requires some understanding of the [Ecto](https://hexdocs.pm/ecto/Ecto.html) project and in particular a reasonably deep understanding of [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). Review the linked Ecto documentation if you find any of the examples difficult to follow. + +## Installing LiveView Native Live Form + +To install LiveView Native Form, we need to add the `liveview-native-live-form` SwiftUI package to our iOS application. + +Follow the [LiveView Native Form Installation Guide](https://github.com/liveview-native/liveview-native-live-form?tab=readme-ov-file#liveviewnativeliveform) on that project's README and come back to this guide after you have finished the installation process. + +## Creating a Basic Form + +Once you have the LiveView Native Form package installed, you can use the `LiveForm` and `LiveSubmitButton` views to build forms more conveniently. + +Here's a basic example of a `LiveForm`. Keep in mind that `LiveForm` requires an `id` attribute. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.ExampleLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +When a form is submitted, its data is sent as a map where each key is the 'name' attribute of the form's control views. Evaluate the example above in your simulator and you will see a map similar to the following: + + + +```elixir +%{"my-text" => "some value"} +``` + +In a real-world application you could use these params to trigger some application logic, such as inserting a record into the database. + +## Controls and Indicators + +We've already covered many individual controls and indicator views that you can use inside of forms. For more information on those, go to the [Interactive SwiftUI Views](https://hexdocs.pm/live_view_native/interactive-swiftui-views.html) guide. + + + +### Your Turn + +Create a form that has `TextField`, `Slider`, `Toggle`, and `DatePicker` fields. + +
+Example Solution + +```elixir +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + + + + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +``` + +
+ + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + # You may use this handler to test your solution. + # You should not need to modify this handler. + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Controlled Values + +Some control views such as the `Stepper` require manually displaying their value. In this case, we can store the form params in the socket and update them everytime the `phx-change` form binding submits an event. You can also use this pattern to provide default values. + +Evaluate the example below to see this in action. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.StepperLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, params: %{"my-stepper" => 1})} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + <%= @params["my-stepper"] %> + + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, assign(socket, params: params)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Secure Field + +For password entry, or anytime you want to hide a given value, you can use the [SecureField](https://developer.apple.com/documentation/swiftui/securefield) view. This field works mostly the same as a `TextField` but hides the visual text. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.SecureLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + Enter a Password + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Keyboard Types + +To format a `TextField` for specific input types we can use the [keyboardType](https://developer.apple.com/documentation/swiftui/view/keyboardtype(_:)) modifier. + +For a complete list of accepted keyboard types, see the [UIKeyboardType](https://developer.apple.com/documentation/uikit/uikeyboardtype) documentation. + +Below we've created several different common keyboard types. We've also included a generic `keyboard-*` to demonstrate how you can make a reusable class. + +```elixir +defmodule KeyboardStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "number-pad" do + keyboardType(.numberPad) + end + + "email-address" do + keyboardType(.emailAddress) + end + + "phone-pad" do + keyboardType(.phonePad) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +Evaluate the example below to see the different keyboards as you focus on each input. If you don't see the keyboard, go to `I/O` -> `Keyboard` -> `Toggle Software Keyboard` to enable the software keyboard in your simulator. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.KeyboardLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use KeyboardStylesheet + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter Phone + Enter Number + Enter Number + """ + end + + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Validation + +In this section, we'll focus mainly on using [Ecto Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) to validate data, but know that this is not the only way to validate data if you would like to write your own custom logic in the form event handlers, you absolutely can. + + + +### LiveView Native Changesets Coming Soon! + +LiveView Native Form doesn't currently natively support [Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) and [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) structs the way a traditional [Phoenix.Component.form](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#form/1) does. However there is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/5) to add this behavior so this may change in the near future. As a result, this section is somewhat more verbose than will be necessary in the future, as we have to manually define much of the error handling logic that we expect will no longer be necessary in version `0.3` of LiveView Native. + +To make error handling easier, we've defined an `ErrorUtils` module below that will handle extracting the error message out of a Changeset. This will not be necessary in future versions of LiveView Native, but is a convenient helper for now. + +```elixir +defmodule ErrorUtils do + def error_message(errors, field) do + with {msg, opts} <- errors[field] do + Server.CoreComponents.translate_error({msg, opts}) + else + _ -> "" + end + end +end +``` + +For the sake of context, the `translate_message/2` function handles formatting Ecto Changeset errors. For example, it will inject values such as `count` into the string. + +```elixir +Server.CoreComponents.translate_error( + {"name must be longer than %{count} characters", [count: 10]} +) +``` + +### Changesets + +Here's a `User` changeset we're going to use to validate a `User` struct's `email` field. + +```elixir +defmodule User do + import Ecto.Changeset + defstruct [:email] + @types %{email: :string} + + def changeset(user, params) do + {user, @types} + |> cast(params, [:email]) + |> validate_required([:email]) + |> validate_format(:email, ~r/@/) + end +end +``` + +We're going to define an `error` class so errors will appear red and be left-aligned. + +```elixir +defmodule ErrorStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + """ +end +``` + +Then, we're going to create a LiveView that uses the `User` changeset to validate data. + +Evaluate the example below and view it in your simulator. We've included and `IO.inspect/2` call to view the changeset after submitting the form. Try submitting the form with different values to understand how those values affect the changeset. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.FormValidationLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use ErrorStylesheet + + @impl true + def mount(_params, _session, socket) do + user_changeset = User.changeset(%User{}, %{}) + {:ok, assign(socket, :user_changeset, user_changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter your email + + <%= ErrorUtils.error_message(@user_changeset.errors, :email) %> + + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # Preserve the `:action` field so errors do not vanish. + |> Map.put(:action, socket.assigns.user_changeset.action) + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # faking a Database insert action + |> Map.put(:action, :insert) + # Submit the form and inspect the logs below to view the changeset. + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +In the code above, the `"sumbit"` and `"validate"` events update the changeset based on the current form params. This fills the `errors` field used by the `ErrorUtils` module to format the error message. + +After submitting the form, the `:action` field of the changeset has a value of `:insert`, so the red Text appears using the `:if` conditional display logic. + +In the future, this complexity will likely be handled by the `live_view_native_form` library, but for now this example exists to show you how to write your own error handling based on changesets if needed. + + + +### Empty Fields Send `"null"`. + +If you submit a form with empty fields, those fields may currently send `"null"`. There is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/6) to fix this bug, but it may affect your form behavior for now and require a temporary workaround until the issue is fixed. + +## Mini Project: User Form + +Taking everything you've learned, you're going to create a more complex user form with data validation and error displaying. We've defined a `FormStylesheet` you can use (and modify) if you would like to style your form. + +```elixir +defmodule FormStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +### User Changeset + +First, create a `CustomUser` changeset below that handles data validation. + +**Requirements** + +* A user should have a `name` field +* A user should have a `password` string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn't have a database. In real-world apps passwords should **never** be stored as a simple string, they should be encrypted. +* A user should have an `age` number field greater than `0` and less than `200`. +* A user should have an `email` field which matches an email format (including `@` is sufficient). +* A user should have a `accepted_terms` field which must be true. +* A user should have a `birthdate` field which is a date. +* All fields should be required + +
+Example Solution + +```elixir +defmodule CustomUser do + import Ecto.Changeset + defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate] + + @types %{ + name: :string, + password: :string, + age: :integer, + email: :string, + accepted_terms: :boolean, + birthdate: :date + } + + def changeset(user, params) do + {user, @types} + |> cast(params, Map.keys(@types)) + |> validate_required(Map.keys(@types)) + |> validate_length(:password, min: 10) + |> validate_number(:age, greater_than: 0, less_than: 200) + |> validate_acceptance(:accepted_terms) + end + + def error_message(changeset, field) do + with {msg, _reason} <- changeset.errors[field] do + msg + else + _ -> "" + end + end +end +``` + +
+ +```elixir +defmodule CustomUser do + # define the struct keys + defstruct [] + + # define the types + @types %{} + + def changeset(user, params) do + # Enter your solution + end +end +``` + +### LiveView + +Next, create the `CustomUserFormLive` Live View that lets the user enter their information and displays errors for invalid information upon form submission. + +**Requirements** + +* The `name` field should be a `TextField`. +* The `email` field should be a `TextField`. +* The `password` field should be a `SecureField`. +* The `age` field should be a `TextField` with a `.numberPad` keyboard or a `Slider`. +* The `accepted_terms` field should be a `Toggle`. +* The `birthdate` field should be a `DatePicker`. + +
+Example Solution + +```elixir +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + changeset = CustomUser.changeset(%CustomUser{}, %{}) + + {:ok, assign(socket, :changeset, changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + name... + <.form_error changeset={@changeset} field={:name}/> + + email... + <.form_error changeset={@changeset} field={:email}/> + + age... + <.form_error changeset={@changeset} field={:age}/> + + password... + <.form_error changeset={@changeset} field={:password}/> + + Accept the Terms and Conditions: + <.form_error changeset={@changeset} field={:accepted_terms}/> + + Birthday: + <.form_error changeset={@changeset} field={:birthdate}/> + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, socket.assigns.changeset.action) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + # While not strictly required, the form_error component reduces code bloat. + def form_error(assigns) do + ~SWIFTUI""" + + <%= CustomUser.error_message(@changeset, @field) %> + + """ + end +end +``` + +
+ + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + # Remember to provide the initial changeset + {:ok, socket} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + @impl true + # Write your `"validate"` event handler + def handle_event("validate", params, socket) do + {:noreply, socket} + end + + # Write your `"submit"` event handler + def handle_event("submit", params, socket) do + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/livebooks/getting-started.livemd b/guides/livebooks/getting-started.livemd new file mode 100644 index 000000000..bc2c2dadc --- /dev/null +++ b/guides/livebooks/getting-started.livemd @@ -0,0 +1,149 @@ +# Getting Started + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +Our livebook guides provide step-by-step lessons to help you learn LiveView Native using Livebook. These guides assume that you already have some familiarity with Phoenix LiveView applications. + +You can read these guides online, or for the best experience we recommend you click on the "Run in Livebook" badge to import and run these guides locally with Livebook. + +Each guide can be completed independently, but we suggest following them chronologically for the most comprehensive learning experience. + +## Prerequisites + +To use these guides, you'll need to install the following prerequisites: + +* [Elixir/Erlang](https://elixir-lang.org/install.html) +* [Livebook](https://livebook.dev/) +* [Xcode](https://developer.apple.com/xcode/) + +While not necessary for our guides, we also recommend you install the following for general LiveView Native development: + +* [Phoenix](https://hexdocs.pm/phoenix/installation.html) +* [PostgreSQL](https://www.postgresql.org/download/) +* [LiveView Native VS Code Extension](https://github.com/liveview-native/liveview-native-vscode) + +## Hello World + +If you are not already running this guide in Livebook, click on the "Run in Livebook" badge at the top of this page to import this guide into Livebook. + +Then, you can evaluate the following smart cell and visit http://localhost:4000 to ensure this Livebook works correctly. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +In an upcoming lesson, you'll set up an iOS application with Xcode so you can run code native examples. + +## Your Turn: Live Reloading + +Change `Hello from LiveView!` to `Hello again from LiveView!` in the above LiveView. Re-evaluate the cell and notice the application live reloads and automatically updates in the browser. + +## Kino LiveView Native + +To run a Phoenix Server setup with LiveView Native from within Livebook we built the [Kino LiveView Native](https://github.com/liveview-native/kino_live_view_native) library. + +Whenever you run one of our Livebooks, a server starts on localhost:4000. Ensure you have no other servers running on port 4000 + +Kino LiveView Native defines the **LiveView Native: LiveView** and **LiveViewNative: Render Component** smart cells within these guides. + +## Troubleshooting + +Some common issues you may encounter are: + +* Another server is already running on port 4000. +* Your version of Livebook needs to be updated. +* Your version of Elixir/Erlang needs to be updated. +* Your version of Xcode needs to be updated. +* This Livebook has cached outdated versions of dependencies + +Ensure you have the latest versions of all necessary software installed, and ensure no other servers are running on port 4000. + +To clear the cache, you can click the `Setup without cache` button revealed by clicking the dropdown next to the `setup` button at the top of the Livebook. + +If that does not resolve the issue, you can [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) to receive support from the LiveView Native team. diff --git a/guides/livebooks/interactive-swiftui-views.livemd b/guides/livebooks/interactive-swiftui-views.livemd new file mode 100644 index 000000000..7f25b39fd --- /dev/null +++ b/guides/livebooks/interactive-swiftui-views.livemd @@ -0,0 +1,947 @@ +# Interactive SwiftUI Views + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + # {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + {:kino_live_view_native, path: "../kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +In this guide, you'll learn how to build interactive LiveView Native applications using event bindings. + +This guide assumes some existing familiarity with [Phoenix Bindings](https://hexdocs.pm/phoenix_live_view/bindings.html) and how to set/access state stored in the LiveView's socket assigns. To get the most out of this material, you should already understand the `assign/3`/`assign/2` function, and how event bindings such as `phx-click` interact with the `handle_event/3` callback function. + +We'll use the following LiveView and define new render component examples throughout the guide. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Event Bindings + +We can bind any available `phx-*` [Phoenix Binding](https://hexdocs.pm/phoenix_live_view/bindings.html) to a SwiftUI Element. However certain events are not available on native. + +LiveView Native currently supports the following events on all SwiftUI views: + +* `phx-window-focus`: Fired when the application window gains focus, indicating user interaction with the Native app. +* `phx-window-blur`: Fired when the application window loses focus, indicating the user's switch to other apps or screens. +* `phx-focus`: Fired when a specific native UI element gains focus, often used for input fields. +* `phx-blur`: Fired when a specific native UI element loses focus, commonly used with input fields. +* `phx-click`: Fired when a user taps on a native UI element, enabling a response to tap events. + +> The above events work on all SwiftUI views. Some events are only available on specific views. For example, `phx-change` is available on controls and `phx-throttle/phx-debounce` is available on views with events. + +There is also a [Pull Request](https://github.com/liveview-native/liveview-client-swiftui/issues/1095) to add Key Events which may have been merged since this guide was published. + +## Basic Click Example + +The `phx-click` event triggers a corresponding [handle_event/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_event/3) callback function whenever a SwiftUI view is pressed. + +In the example below, the client sends a `"ping"` event to the server, and trigger's the LiveView's `"ping"` event handler. + +Evaluate the example below, then click the `"Click me!"` button. Notice `"Pong"` printed in the server logs below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("ping", _params, socket) do + IO.puts("Pong") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Click Events Updating State + +Event handlers in LiveView can update the LiveView's state in the socket. + +Evaluate the cell below to see an example of incrementing a count. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn: Decrement Counter + +You're going to take the example above, and create a counter that can **both increment and decrement**. + +There should be two buttons, each with a `phx-click` binding. One button should bind the `"decrement"` event, and the other button should bind the `"increment"` event. Each event should have a corresponding handler defined using the `handle_event/3` callback function. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("decrement", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Selectable Lists + +`List` views support selecting items within the list based on their id. To select an item, provide the `selection` attribute with the item's id. + +Pressing a child item in the `List` on a native device triggers the `phx-change` event. In the example below we've bound the `phx-change` event to send the `"selection-changed"` event. This event is then handled by the `handle_event/3` callback function and used to change the selected item. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Item <%= i %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, selection: "None")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Expandable Lists + +`List` views support hierarchical content using the [DisclosureGroup](https://developer.apple.com/documentation/swiftui/disclosuregroup) view. Nest `DisclosureGroup` views within a list to create multiple levels of content as seen in the example below. + +To control a `DisclosureGroup` view, use the `is-expanded` boolean attribute as seen in the example below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + Item 2 + Item 3 + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :is_expanded, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-expanded" => is_expanded}, socket) do + {:noreply, assign(socket, is_expanded: !is_expanded)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Multiple Expandable Lists + +The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + + Level 2 + Item 2 + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle-" <> level, %{"is-expanded" => is_expanded}, socket) do + level = String.to_integer(level) + + {:noreply, + assign( + socket, + :expanded_groups, + Map.replace!(socket.assigns.expanded_groups, level, !is_expanded) + )} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Controls and Indicators + +In Phoenix, the `phx-change` event must be applied to a parent form. However in SwiftUI there is no similar concept of forms. Instead, SwiftUI provides [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) views. We can apply the `phx-change` binding to any of these views. + +Once bound, the SwiftUI view will send a message to the LiveView anytime the control or indicator changes its value. + +The params of the message are based on the name of the [Binding](https://developer.apple.com/documentation/swiftui/binding) argument of the view's initializer in SwiftUI. + + + +### Event Value Bindings + +Many views use the `value` binding argument, so event params are generally sent as `%{"value" => value}`. However, certain views such as `TextField` and `Toggle` deviate from this pattern because SwiftUI uses a different `value` binding argument. For example, the `TextField` view uses `text` to bind its value, so it sends the event params as `%{"text" => value}`. + +When in doubt, you can connect the event handler and inspect the params to confirm the shape of map. + +## Text Field + +The following example shows you how to connect a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) with a `phx-change` event binding to a corresponding event handler. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the server logs in the console below as a map of `%{"text" => value}`. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", params, socket) do + IO.inspect(params, label: "params") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Storing TextField Values in the Socket + +The following example demonstrates how to set/access a TextField's value by controlling it using the socket assigns. + +This pattern is useful when rendering the TextField's value elsewhere on the page, using the `TextField` view's value in other event handler logic, or to set an initial value. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + + The current value: <%= @text %> + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :text, "initial value")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", %{"text" => text}, socket) do + {:noreply, assign(socket, :text, text)} + end + + @impl true + def handle_event("pretty-print", _params, socket) do + IO.puts(""" + ================== + #{socket.assigns.text} + ================== + """) + + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Slider + +This code example renders a SwiftUI [Slider](https://developer.apple.com/documentation/swiftui/slider). It triggers the change event when the slider is moved and sends a `"slide"` message. The `"slide"` event handler then logs the value to the console. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the console below as a map of `%{"value" => value}`. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Percent Completed + 0% + 100% + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("slide", params, socket) do + IO.inspect(params, label: "Slide Params") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Stepper + +This code example renders a SwiftUI [Stepper](https://developer.apple.com/documentation/swiftui/stepper). It triggers the change event and sends a `"change-tickets"` message when the stepper increments or decrements. The `"change-tickets"` event handler then updates the number of tickets stored in state, which appears in the UI. + +Evaluate the example and increment/decrement the step. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Tickets <%= @tickets %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :tickets, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change-tickets", %{"value" => tickets}, socket) do + {:noreply, assign(socket, :tickets, tickets)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Toggle + +This code example renders a SwiftUI [Toggle](https://developer.apple.com/documentation/swiftui/toggle). It triggers the change event and sends a `"toggle"` message when toggled. The `"toggle"` event handler then updates the `:on` field in state, which allows the `Toggle` view to be toggled on. Without providing the `is-on` attribute, the `Toggle` view could not be flipped on and off. + +Evaluate the example below and click on the toggle. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + On/Off + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :on, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-on" => on}, socket) do + {:noreply, assign(socket, :on, on)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## DatePicker + +The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :date, nil)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", params, socket) do + IO.inspect(params, label: "Date Params") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Parsing Dates + +The date from the `DatePicker` is in iso8601 format. You can use the `from_iso8601` function to parse this string into a `DateTime` struct. + +```elixir +iso8601 = "2024-01-17T20:51:00.000Z" + +DateTime.from_iso8601(iso8601) +``` + +### Your Turn: Displayed Components + +The `DatePicker` view accepts a `displayed-components` attribute with the value of `"hour-and-minute"` or `"date"` to only display one of the two components. By default, the value is `"all"`. + +You're going to change the `displayed-components` attribute in the example below to see both of these options. Change `"all"` to `"date"`, then to `"hour-and-minute"`. Re-evaluate the cell between changes and see the updated UI. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + def handle_event("pick-date", params, socket) do + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Small Project: Todo List + +Using the previous examples as inspiration, you're going to create a todo list. + +**Requirements** + +* Items should be `Text` views rendered within a `List` view. +* Item ids should be stored in state as a list of integers i.e. `[1, 2, 3, 4]` +* Use a `TextField` to provide the name of the next added todo item. +* An add item `Button` should add items to the list of integers in state when pressed. +* A delete item `Button` should remove the currently selected item from the list of integers in state when pressed. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Todo... + + + + <%= content %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type-name", %{"text" => name}, socket) do + {:noreply, assign(socket, :item_name, name)} + end + + def handle_event("add-item", _params, socket) do + updated_items = [ + {"item-#{socket.assigns.next_item_id}", socket.assigns.item_name} + | socket.assigns.items + ] + + {:noreply, + assign(socket, + item_name: "", + items: updated_items, + next_item_id: socket.assigns.next_item_id + 1 + )} + end + + def handle_event("delete-item", _params, socket) do + updated_items = + Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end) + {:noreply, assign(socket, :items, updated_items)} + end + + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + # Define your mount/3 callback + + @impl true + def render(assigns), do: ~H"" + + # Define your render/3 callback + + # Define any handle_event/3 callbacks +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/livebooks/native-navigation.livemd b/guides/livebooks/native-navigation.livemd new file mode 100644 index 000000000..0188e0d1d --- /dev/null +++ b/guides/livebooks/native-navigation.livemd @@ -0,0 +1,410 @@ +# Native Navigation + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +This guide will teach you how to create multi-page applications using LiveView Native. We will cover navigation patterns specific to native applications and how to reuse the existing navigation patterns available in LiveView. + +Before diving in, you should have a basic understanding of navigation in LiveView. You should be familiar with the [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions, which are used to trigger navigation from within a LiveView. Additionally, you should know how to define routes in the router using the [live/4](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4) macro. + +## NavigationStack + +LiveView Native applications are generally wrapped in a [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view. This view usually exists in the `root.swiftui.heex` file, which looks something like the following: + + + +```elixir +<.csrf_token /> + + +
+ Hello, from LiveView Native! +
+
+``` + +Notice the [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view wraps the template. This view manages the state of navigation history and allows for navigating back to previous pages. + +## Navigation Links + +We can use the [NavigationLink](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/navigationlink) view for native navigation, similar to how we can use the [.link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with the `navigate` attribute for web navigation. + +We've created the same example of navigating between the `Main` and `About` pages. Each page using a `NavigationLink` to navigate to the other page. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + To Home + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/about") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the main page + + To About + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +The `destination` attribute works the same as the `navigate` attribute on the web. The current LiveView will shut down, and a new one will mount without re-establishing a new socket connection. + +## Push Navigation + +For LiveView Native views, we can still use the same [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2), and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions used in typical LiveViews. + +These functions are preferable over `NavigationLink` views when you want to share navigation handlers between web and native, and/or when you want to have more customized navigation handling. + +Evaluate **both** of the code cells below and click on the `Button` view in your simulator that triggers the `handle_event/3` navigation handler to navigate between the two views. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.MainLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the Main Page + + """ + end +end + +defmodule ServerWeb.MainLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-about", _params, socket) do + {:noreply, push_navigate(socket, to: "/about")} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the About Page + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-main", _params, socket) do + {:noreply, push_navigate(socket, to: "/")} + end +end +|> Server.SmartCells.LiveViewNative.register("/about") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Routing + +The `KinoLiveViewNative` smart cells used in this guide automatically define routes for us. Be aware there is no difference between how we define routes for LiveView or LiveView Native. + +The routes for the main and about pages might look like the following in the router: + + + +```elixir +live "/", Server.MainLive +live "/about", Server.AboutLive +``` + +## Native Navigation Events + +LiveView Native navigation mirrors the same navigation behavior you'll find on the web. + +Evaluate the example below and press each button. Notice that: + +1. `redirect/2` triggers the `mount/3` callback re-establishes a socket connection. +2. `push_navigate/2` triggers the `mount/3` callbcak and re-uses the existing socket connection. +3. `push_patch/2` does not trigger the `mount/3` callback, but does trigger the `handle_params/3` callback. This is often useful when using navigation to trigger page changes such as displaying a modal or overlay. + +You can see this for yourself using the following example. Click each of the buttons for redirect, navigate, and patch behavior. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +# This module built for example purposes to persist logs between mounting LiveViews. +defmodule PersistantLogs do + def get do + :persistent_term.get(:logs) + end + + def put(log) when is_binary(log) do + :persistent_term.put(:logs, [{log, Time.utc_now()} | get()]) + end + + def reset do + :persistent_term.put(:logs, []) + end +end + +PersistantLogs.reset() + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + + + Socket ID<%= @socket_id %> + LiveView PID:<%= @live_view_pid %> + <%= for {log, time} <- Enum.reverse(@logs) do %> + + <%= Calendar.strftime(time, "%H:%M:%S") %>: + <%= log %> + + <% end %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + PersistantLogs.put("MOUNT") + + {:ok, + assign(socket, + socket_id: socket.id, + connected: connected?(socket), + logs: PersistantLogs.get(), + live_view_pid: inspect(self()) + )} + end + + @impl true + def handle_params(_params, _url, socket) do + PersistantLogs.put("HANDLE PARAMS") + + {:noreply, assign(socket, :logs, PersistantLogs.get())} + end + + @impl true + def render(assigns), + do: ~H"" + + @impl true + def handle_event("redirect", _params, socket) do + PersistantLogs.reset() + PersistantLogs.put("--REDIRECTING--") + {:noreply, redirect(socket, to: "/")} + end + + def handle_event("navigate", _params, socket) do + PersistantLogs.put("---NAVIGATING---") + {:noreply, push_navigate(socket, to: "/")} + end + + def handle_event("patch", _params, socket) do + PersistantLogs.put("----PATCHING----") + {:noreply, push_patch(socket, to: "/")} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/livebooks/stylesheets.livemd b/guides/livebooks/stylesheets.livemd new file mode 100644 index 000000000..0b93678ef --- /dev/null +++ b/guides/livebooks/stylesheets.livemd @@ -0,0 +1,659 @@ +# Stylesheets + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +In this guide, you'll learn how to use stylesheets to customize the appearance of your LiveView Native Views. You'll also learn about the inner workings of how LiveView Native uses stylesheets to implement modifiers, and how those modifiers style and customize SwiftUI Views. By the end of this lesson, you'll have the fundamentals you need to create beautiful native UIs. + +## The Stylesheet AST + +LiveView Native parses through your application at compile time to create a stylesheet AST representation of all the styles in your application. This stylesheet AST is used by the LiveView Native Client application when rendering the view hierarchy to apply modifiers to a given view. + +```mermaid +sequenceDiagram + LiveView->>LiveView: Create stylesheet + Client->>LiveView: Send request to "http://localhost:4000/?_format=swiftui" + LiveView->>Client: Send LiveView Native template in response + Client->>LiveView: Send request to "http://localhost:4000/assets/app.swiftui.styles" + LiveView->>Client: Send stylesheet in response + Client->>Client: Parses stylesheet into SwiftUI modifiers + Client->>Client: Apply modifiers to the view hierarchy +``` + +We've setup this Livebook to be included when parsing the application for modifiers. You can visit http://localhost:4000/assets/app.swiftui.styles to see the Stylesheet AST created by all of the styles in this Livebook and any other styles used in the `kino_live_view_native` project. + +LiveView Native watches for changes and updates the stylesheet, so those will be dynamically picked up and applied, You may notice a slight delay as the Livebook takes **5 seconds** to write its contents to a file. + +## Modifiers + +SwiftUI employs **modifiers** to style and customize views. In SwiftUI syntax, each modifier is a function that can be chained onto the view they modify. LiveView Native has a minimal DSL (Domain Specific Language) for writing SwiftUI modifiers. + +Modifers can be applied through a LiveView Native Stylesheet and applying them through classes as described in the [LiveView Native Stylesheets](#liveview-native-stylesheets) section, or can be applied directly through the `class` attribute as described in the [Utility Styles](#utility-styles) section. + + + +### SwiftUI Modifiers + +Here's a basic example of making text red using the [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) modifier: + +```swift +Text("Some Red Text") + .foregroundStyle(.red) +``` + +Many modifiers can be applied to a view. Here's an example using [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) and [frame](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)). + +```swift +Text("Some Red Text") + .foregroundStyle(.red) + .font(.title) +``` + + + +### Implicit Member Expression + +Implicit Member Expression in SwiftUI means that we can implicityly access a member of a given type without explicitly specifying the type itself. For example, the `.red` value above is from the [Color](https://developer.apple.com/documentation/swiftui/color) structure. + +```swift +Text("Some Red Text") + .foregroundStyle(Color.red) +``` + + + +### LiveView Native Modifiers + +The DSL (Domain Specific Language) used in LiveView Native drops the `.` dot before each modifier, but otherwise remains largely the same. We do not document every modifier separately, since you can translate SwiftUI examples into the DSL syntax. + +For example, Here's the same `foregroundStyle` modifier as it would be written in a LiveView Native stylesheet or class attribute, which we'll cover in a moment. + +```swift +foregroundStyle(.red) +``` + +There are some exceptions where the DSL differs from SwiftUI syntax, which we'll cover in the sections below. + +## Utility Styles + +In addition to introducing stylesheets, LiveView Native `0.3.0` also introduced Utility classes, which will be our prefered method for writing styles in these Livebook guides. + +Utility styles are comperable to inline styles in HTML, which have been largely discouraged in the CSS community. We recommend Utility styles for now as the easiest way to prototype applications. But, we hope to replace Utility styles with a more mature styling framework in the future. + +The same SwiftUI syntax used inside of a stylesheet can be used directly inside of a `class` attribute. The example below defines the `foregroundStyle(.red)` modifier. Evaluate the example and view it in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Multiple Modifiers + +You can write multiple modifiers, separate each by a space or newline character. + +```html +Hello, from LiveView Native! +``` + +For newline characters, you'll need to wrap the string in curly brackets `{}`. Using multiple lines can better organize larger amounts of modifiers. + +```html + +Hello, from LiveView Native! + +``` + + + +### Spaces + +At the time of writing, the parser for utility styles interprets space characters as a separator for each rule, thus you should not includes spaces in modifiers that might traditionally have a space. + +```html +Hello, from LiveView Native! +``` + +## Dynamic Class Names + +LiveView Native parses styles in your project to define a single stylesheet. You can find the AST representation of this stylesheet at http://localhost:4000/assets/app.swiftui.styles. This stylesheet is compiled on the server and then sent to the client. For this reason, class names must be fully-formed. For example, the following class using string interpolation is **invalid**. + +```html + +Invalid Example + +``` + +However, we can still use dynamic styles so long as the class names are fully formed. + +```html + +Red or Blue Text + +``` + +Evaluate the example below multiple times while watching your simulator. Notice that the text is dynamically red or blue. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Modifier Order + +Modifier order matters. Changing the order that modifers are applied can have a significant impact on their behavior. + +To demonstrate this concept, we're going to take a simple example of applying padding and background color. + +If we apply the background color first, then the padding, The background is applied to original view, leaving the padding filled with whitespace. + + + +```elixir +background(.orange) +padding(20) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style View fill:orange +``` + +If we apply the padding first, then the background, the background is applied to the view with the padding, thus filling the entire area with background color. + + + +```elixir +padding(20) +background(.orange) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style Padding fill:orange +style View fill:orange +``` + +Evaluate the example below to see this in action. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Injecting Views in Stylesheets + +SwiftUI modifiers sometimes accept SwiftUI views as arguments. Here's an example using the `clipShape` modifier with a `Circle` view. + +```swift +Image("logo") + .clipShape(Circle()) +``` + +However, LiveView Native does not support using SwiftUI views directly within a stylesheet. Instead, we have a few alternative options in cases like this where we want to use a view within a modifier. + + + +### Using Members on a Given Type + +We can't use the [Circle](https://developer.apple.com/documentation/swiftui/circle) view directly. However, if you look at the [clipShape](https://developer.apple.com/documentation/swiftui/view/clipshape(_:style:)) documentation you'll notice it accepts the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. This type defines the [circle](https://developer.apple.com/documentation/swiftui/shape/circle) property which we can use since it's equivalent to the [Circle](https://developer.apple.com/documentation/swiftui/circle) view for our purposes. + +We can use `Shape.circle` instead of the `Circle` view. So, the following code is equivalent to the example above. + +```swift +Image("logo") + .clipShape(Shape.circle) +``` + +Using implicit member expression, we can simplify this code to the following: + +```swift +Image("logo") + .clipShape(.circle) +``` + +Which is simple to convert to the LiveView Native DSL using the rules we've already learned. + + + +```elixir +"example-class" do + clipShape(.circle) +end +``` + + + +### Injecting a View + +For more complex cases, we can inject a view directly into a stylesheet. + +Here's an example where this might be useful. SwiftUI has modifers that represent a named content area for views to be placed within. These views can even have their own modifiers, so it's not enough to use a simple static property on the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. + +```swift +Image("logo") + .overlay(content: { + Circle().stroke(.red, lineWidth: 4) + }) +``` + +To get around this issue, we instead inject a view into the stylesheet. First, define the modifier and use an atom to represent the view that's going to be injected. + + + +```elixir +"overlay-circle" do + overlay(content: :circle) +end +``` + +Then use the `template` attribute on the view to be injected into the stylesheet. This view should be a child of the view with the given class. + +```html + + + +``` + +We can then apply modifiers to the child view through a class as we've already seen. + +## Custom Colors + +### SwiftUI Color Struct + +The SwiftUI [Color](https://developer.apple.com/documentation/swiftui/color) structure accepts either the name of a color in the asset catalog or the RGB values of the color. + +Therefore we can define custom RBG styles like so: + +```swift +foregroundStyle(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) +``` + +Evaluate the example below to see the custom color in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Custom Colors in the Asset Catalogue + +Custom colors can be defined in the [Asset Catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Once defined in the asset catalogue of the Xcode application, the color can be referenced by name like so: + +```swift +foregroundStyle(Color("MyColor")) +``` + +Generally using the asset catalog is more performant and customizable than using custom RGB colors with the [Color](https://developer.apple.com/documentation/swiftui/color) struct. + + + +### Your Turn: Custom Colors in the Asset Catalog + +Custom colors can be defined in the asset catalog (https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Generat + +To create a new color go to the `Assets` folder in your iOS app and create a new color set. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-create-new-color-set.png?raw=true) + + + +To create a color set, enter the RGB values or a hexcode as shown in the image below. If you don't see the sidebar with color options, click the icon in the top-right of your Xcode app and click the **Show attributes inspector** icon shown highlighted in blue. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-modify-my-color.png?raw=true) + + + +The defined color is now available for use within LiveView Native styles. However, the app needs to be re-compiled to pick up a new color set. + +Re-build your SwiftUI Application before moving on. Then evaluate the code below. You should see your custom colored text in the simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## LiveView Native Stylesheets + +In LiveView Native, we use `~SHEET` sigil stylesheets to organize modifers by classes using an Elixir-oriented DSL similar to CSS for styling web elements. + +We group modifiers together within a class that can be applied to an element. Here's an example of how modifiers can be grouped into a "red-title" class in a stylesheet: + + + +```elixir +~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end +""" +``` + +We're mostly using Utility styles for these guides, but the stylesheet module does contain some important configuration to `@import` the utility styles module. It can also be used to group styles within a class if you have a set of modifiers you're repeatedly using and want to group together. + + + +```elixir +defmodule ServerWeb.Styles.App.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + @import LiveViewNative.SwiftUI.UtilityStyles + + ~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end + """ +end +``` + +Since the Phoenix server runs in a dependency for these guides, you don't have direct access to the stylesheet module. + +## Apple Documentation + +You can find documentation and examples of modifiers on [Apple's SwiftUI documentation](https://developer.apple.com/documentation/swiftui) which is comprehensive and thorough, though it may feel unfamiliar at first for Elixir Developers when compared to HexDocs. + + + +### Finding Modifiers + +The [Configuring View Elements](https://developer.apple.com/documentation/swiftui/view#configuring-view-elements) section of apple documentation contains links to modifiers organized by category. In that documentation you'll find useful references such as [Style Modifiers](https://developer.apple.com/documentation/swiftui/view-style-modifiers), [Layout Modifiers](https://developer.apple.com/documentation/swiftui/view-layout), and [Input and Event Modifiers](https://developer.apple.com/documentation/swiftui/view-input-and-events). + +You can also find more on modifiers with LiveView Native examples on the [liveview-client-swiftui](https://hexdocs.pm/live_view_native_swiftui) HexDocs. + +## Visual Studio Code Extension + +If you use Visual Studio Code, we strongly recommend you install the [LiveView Native Visual Studio Code Extension](https://github.com/liveview-native/liveview-native-vscode) which provides autocompletion and type information thus making modifiers significantly easier to write and lookup. + +## Your Turn: Syntax Conversion + +Part of learning LiveView Native is learning SwiftUI. Fortunately we can leverage the existing SwiftUI ecosystem and convert examples into LiveView Native syntax. + +You're going to convert the following SwiftUI code into a LiveView Native template. This example is inspired by the official [SwiftUI Tutorials](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + +```elixir + VStack { + VStack(alignment: .leading) { + Text("Turtle Rock") + .font(.title) + HStack { + Text("Joshua Tree National Park") + Spacer() + Text("California") + } + .font(.subheadline) + + Divider() + + Text("About Turtle Rock") + .font(.title2) + Text("Descriptive text goes here") + } + .padding() + + Spacer() +} +``` + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Turtle Rock + + Joshua Tree National Park + + California + + + About Turtle Rock + Descriptive text goes here + + """ + end +end +``` + +
+ +Enter your solution below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/livebooks/swiftui-views.livemd b/guides/livebooks/swiftui-views.livemd new file mode 100644 index 000000000..326f6198a --- /dev/null +++ b/guides/livebooks/swiftui-views.livemd @@ -0,0 +1,937 @@ +# SwiftUI Views + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {127, 0, 0, 1}, port: 4000], + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +LiveView Native aims to use minimal SwiftUI code. All patterns for building interactive UIs are the same as LiveView. However, unlike LiveView for the web, LiveView Native uses SwiftUI templates to build the native UI. + +This lesson will teach you how to build SwiftUI templates using common SwiftUI views. We'll cover common uses of each view and give you practical examples you can use to build your own native UIs. This lesson is like a recipe book you can refer back to whenever you need an example of how to use a particular SwiftUI view. In addition, once you understand how to convert these views into the LiveView Native DSL, you should have the tools to convert essentially any SwiftUI View into the LiveView Native DSL. + +## Render Components + +LiveView Native `0.3.0` introduced render components to better encourage isolation of native and web templates and move away from co-location templates within the same LiveView module. + +Render components are namespaced under the main LiveView, and are responsible for defining the `render/1` callback function that returns the native template. + +For example, and `ExampleLive` LiveView module would have an `ExampleLive.SwiftUI` render component module for the native Template. + +This `ExampleLive.SwiftUI` render component may define a `render/1` callback function as seen below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +# Render Component +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +# LiveView +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +Throughout this and further material we'll re-define render components you can evaluate and see reflected in your Xcode iOS simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from a LiveView Native Render Component! + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Embedding Templates + +Alternatively, you may omit the render callback and instead define a `.neex` (Native + Embedded Elixir) template. + +By default, the module above would look for a template in the `swiftui/example_live*` path relative to the module's location. You can see the `LiveViewNative.Component` documentation for further explanation. + +For the sake of ease when working in Livebook, we'll prefer defining the `render/1` callback. However, we recommend you generally prefer template files when working locally in Phoenix LiveView Native projects. + +## SwiftUI Views + +In SwiftUI, a "View" is like a building block for what you see on your app's screen. It can be something simple like text or an image, or something more complex like a layout with multiple elements. Views are the pieces that make up your app's user interface. + +Here's an example `Text` view that represents a text element. + +```swift +Text("Hamlet") +``` + +LiveView Native uses the following syntax to represent the view above. + + + +```elixir +Hamlet +``` + +SwiftUI provides a wide range of Views that can be used in native templates. You can find a full reference of these views in the SwiftUI Documentation at https://developer.apple.com/documentation/swiftui/. You can also find a shorthand on how to convert SwiftUI syntax into the LiveView Native DLS in the [LiveView Native Syntax Conversion Cheatsheet](https://hexdocs.pm/live_view_native/cheatsheet.cheatmd). + +## Text + +We've already seen the [Text](https://developer.apple.com/documentation/swiftui/text) view, but we'll start simple to get the interactive tutorial running. + +Evaluate the cell below, then in Xcode, Start the iOS application you created in the [Create a SwiftUI Application](https://hexdocs.pm/live_view_native/create-a-swiftui-application.html) lesson and ensure you see the `"Hello, from LiveView Native!"` text. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## HStack and VStack + +SwiftUI includes many [Layout](https://developer.apple.com/documentation/swiftui/layout-fundamentals) container views you can use to arrange your user Interface. Here are a few of the most commonly used: + +* [VStack](https://developer.apple.com/documentation/swiftui/vstack): Vertically arranges nested views. +* [HStack](https://developer.apple.com/documentation/swiftui/hstack): Horizontally arranges nested views. + +Below, we've created a simple 3X3 game board to demonstrate how to use `VStack` and `HStack` to build a layout of horizontal rows in a single vertical column.o + +Here's a diagram to demonstrate how these rows and columns create our desired layout. + +```mermaid +flowchart +subgraph VStack + direction TB + subgraph H1[HStack] + direction LR + 1[O] --> 2[X] --> 3[X] + end + subgraph H2[HStack] + direction LR + 4[X] --> 5[O] --> 6[O] + end + subgraph H3[HStack] + direction LR + 7[X] --> 8[X] --> 9[O] + end + H1 --> H2 --> H3 +end +``` + +Evaluate the example below and view the working 3X3 layout in your Xcode simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn: 3x3 board using columns + +In the cell below, use `VStack` and `HStack` to create a 3X3 board using 3 columns instead of 3 rows as demonstrated above. The arrangement of `X` and `O` does not matter, however the content will not be properly aligned if you do not have exactly one character in each `Text` element. + +```mermaid +flowchart +subgraph HStack + direction LR + subgraph V1[VStack] + direction TB + 1[O] --> 2[X] --> 3[X] + end + subgraph V2[VStack] + direction TB + 4[X] --> 5[O] --> 6[O] + end + subgraph V3[VStack] + direction TB + 7[X] --> 8[X] --> 9[O] + end + V1 --> V2 --> V3 +end +``` + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Grid + +`VStack` and `HStack` do not provide vertical-alignment between horizontal rows. Notice in the following example that the rows/columns of the 3X3 board are not aligned, just centered. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + X + X + + + X + O + O + + + X + O + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +Fortunately, we have a few common elements for creating a grid-based layout. + +* [Grid](https://developer.apple.com/documentation/swiftui/grid): A grid that arranges its child views in rows and columns that you specify. +* [GridRow](https://developer.apple.com/documentation/swiftui/gridrow): A view that arranges its children in a horizontal line. + +A grid layout vertically and horizontally aligns elements in the grid based on the number of elements in each row. + +Evaluate the example below and notice that rows and columns are aligned. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + XX + X + X + + + X + X + + + X + X + X + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## List + +The SwiftUI [List](https://developer.apple.com/documentation/swiftui/list) view provides a system-specific interface, and has better performance for large amounts of scrolling elements. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + Item 1 + Item 2 + Item 3 + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Multi-dimensional lists + +Alternatively we can separate children within a `List` view in a `Section` view as seen in the example below. Views in the `Section` can have the `template` attribute with a `"header"` or `"footer"` value which controls how the content is displayed above or below the section. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + +
+ Header + Content + Footer +
+
+ """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## ScrollView + +The SwiftUI [ScrollView](https://developer.apple.com/documentation/swiftui/scrollview) displays content within a scrollable region. ScrollView is often used in combination with [LazyHStack](https://developer.apple.com/documentation/swiftui/lazyvstack), [LazyVStack](https://developer.apple.com/documentation/swiftui/lazyhstack), [LazyHGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid), and [LazyVGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid) to create scrollable layouts optimized for displaying large amounts of data. + +While `ScrollView` also works with typical `VStack` and `HStack` views, they are not optimal choices for large amounts of data. + + + +### ScrollView with VStack + +Here's an example using a `ScrollView` and a `HStack` to create scrollable text arranged horizontally. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### ScrollView with HStack + +By default, the [axes](https://developer.apple.com/documentation/swiftui/scrollview/axes) of a `ScrollView` is vertical. To make a horizontal `ScrollView`, set the `axes` attribute to `"horizontal"` as seen in the example below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Optimized ScrollView with LazyHStack and LazyVStack + +`VStack` and `HStack` are inefficient for large amounts of data because they render every child view. To demonstrate this, evaluate the example below. You should experience lag when you attempt to scroll. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +To resolve the performance problem for large amounts of data, you can use the Lazy views. Lazy views only create items as needed. Items won't be rendered until they are present on the screen. + +The next example demonstrates how using `LazyVStack` instead of `VStack` resolves the performance issue. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Spacers + +[Spacers](https://developer.apple.com/documentation/swiftui/spacer) take up all remaining space in a container. + +![Apple Documentation](https://docs-assets.developer.apple.com/published/189fa436f07ed0011bd0c1abeb167723/Building-Layouts-with-Stack-Views-4@2x.png) + +> Image originally from https://developer.apple.com/documentation/swiftui/spacer + +Evaluate the following example and notice the `Text` element is pushed to the right by the `Spacer`. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + This text is pushed to the right + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn: Bottom Text Spacer + +In the cell below, use `VStack` and `Spacer` to place text in the bottom of the native view. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Hello + + """ + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## AsyncImage + +`AsyncImage` is best for network images, or images served by the Phoenix server. + +Here's an example of `AsyncImage` with a lorem picsum image from https://picsum.photos/400/600. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Loading Spinner + +`AsyncImage` displays a loading spinner while loading the image. Here's an example of using `AsyncImage` without a URL so that it loads forever. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Relative Path + +For images served by the Phoenix server, LiveView Native evaluates URLs relative to the LiveView's host URL. This way you can use the path to static resources as you normally would in a Phoenix application. + +For example, the path `/images/logo.png` evaluates as http://localhost:4000/images/logo.png below. This serves the LiveView Native logo. + +Evaluate the example below to see the LiveView Native logo in the iOS simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Image + +The `Image` element is best for system images such as the built in [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols) or images placed into the SwiftUI [asset catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). + + + +### System Images + +You can use the `system-image` attribute to provide the name of system images to the `Image` element. + +For the full list of SF Symbols you can download Apple's [Symbols 5](https://developer.apple.com/sf-symbols/) application. + +Evaluate the cell below to see an example using the `square.and.arrow.up` symbol. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn: Asset Catalogue + +You can place assets in your SwiftUI application's asset catalogue. Using the asset catalogue for SwiftUI assets provide many benefits such as device-specific image variants, dark mode images, high contrast image mode, and improved performance. + +Follow this guide: https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs#Add-a-new-asset to create a new asset called Image. + +Then evaluate the following example and you should see this image in your simulator. For a convenient image, you can right-click and save the following LiveView Native logo. + +![LiveView Native Logo](https://github.com/liveview-native/documentation_assets/blob/main/logo.png?raw=true) + +You will need to **rebuild the native application** to pick up the changes to the assets catalogue. + + + +### Enter Your Solution Below + +You should not need to make changes to this cell. Set up an image in your asset catalogue named "Image", rebuild your native application, then evaluate this cell. You should see the image in your iOS simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Button + +A Button is a clickable SwiftUI View. + +The label of a button can be any view, such as a [Text](https://developer.apple.com/documentation/swiftui/text) view for text-only buttons or a [Label](https://developer.apple.com/documentation/swiftui/label) view for buttons with icons. + +Evaluate the example below to see the SwiftUI [Button](https://developer.apple.com/documentation/swiftui/button) element. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Further Resources + +See the [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui) for a complete list of SwiftUI elements and the [LiveView Native SwiftUI Documentation](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/) for LiveView Native examples of the SwiftUI elements. diff --git a/guides/markdown_livebooks/create-a-swiftui-application.md b/guides/markdown_livebooks/create-a-swiftui-application.md new file mode 100644 index 000000000..e337fa751 --- /dev/null +++ b/guides/markdown_livebooks/create-a-swiftui-application.md @@ -0,0 +1,213 @@ + + +# Create a SwiftUI Application + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%create-a-swiftui-application.livemd) + +## Overview + +This guide will teach you how to set up a SwiftUI Application for LiveView Native. + +Typically, we recommend using the `mix lvn.install` task as described in the [Installation Guide](https://hexdocs.pm/live_view_native/installation.html#5-enable-liveview-native) to add LiveView Native to a Phoenix project. However, we will walk through the steps of manually setting up an Xcode iOS project to learn how the iOS side of a LiveView Native application works. + +In future lessons, you'll use this iOS application to view iOS examples in the Xcode simulator (or a physical device if you prefer.) + +## Prerequisites + +First, make sure you have followed the [Getting Started](https://hexdocs.pm/live_view_native/getting_started.md) guide. Then evaluate the smart cell below and visit http://localhost:4000 to ensure the Phoenix server runs properly. You should see the text `Hello from LiveView!` + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Create the iOS Application + +Open Xcode and select Create New Project. + + + +![Xcode Create New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-new-project.png?raw=true) + + + +Select the `iOS` and `App` options to create an iOS application. Then click `Next`. + + + +![Xcode Create Template For New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-template-for-new-project.png?raw=true) + + + +Choose options for your new project that match the following image, then click `Next`. + +### What do these options mean? + +* **Product Name:** The name of the application. This can be any valid name. We've chosen `Guides`. +* **Organization Identifier:** A reverse DNS string that uniquely identifies your organization. If you don't have a company identifier, [Apple recomends](https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app) using `com.example.your_name` where `your_name` is your organization or personal name. +* **Interface:**: The Xcode user interface to use. Select **SwiftUI** to create an app that uses the SwiftUI app lifecycle. +* **Language:** Determines which language Xcode should use for the project. Select `Swift`. + + + + +![Xcode Choose Options For Your New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-choose-options-for-your-new-project.png?raw=true) + + + +Select an appropriate folder location where you would like to store the iOS project, then click `Create`. + + + +![Xcode select folder location](https://github.com/liveview-native/documentation_assets/blob/main/xcode-select-folder-location.png?raw=true) + + + +You should see the default iOS application generated by Xcode. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/default-xcode-app.png?raw=true) + +## Add the LiveView Client SwiftUI Package + +In Xcode from the project you just created, select `File -> Add Package Dependencies`. Then, search for `liveview-client-swiftui`. Once you have selected the package, click `Add Package`. + +The image below was created using version `0.2.0`. You should select whichever is the latest version of LiveView Native. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/add-liveview-swiftui-client-package-0.2.0.png?raw=true) + + + +Choose the Package Products for `liveview-client-swiftui`. Select `Guides` as the target for `LiveViewNative` and `LiveViewNativeStylesheet`. This adds both of these dependencies to your iOS project. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/select-package-products.png?raw=true) + + + +At this point, you'll need to enable permissions for plugins used by LiveView Native. +You should see the following prompt. Click `Trust & Enable All`. + + + +![Xcode some build plugins are disabled](https://github.com/liveview-native/documentation_assets/blob/main/xcode-some-build-plugins-are-disabled.png?raw=true) + + + +You'll also need to manually navigate to the error tab (shown below) and manually trust and enable packages. Click on each error to trigger a prompt. Select `Trust & Enable All` to enable the plugin. + +The specific plugins are subject to change. At the time of writing you need to enable `LiveViewNativeStylesheetMacros`, `LiveViewNativeMacros`, and `CasePathMacros` as shown in the images below. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-stylesheet.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-macros.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-case-path-macros.png?raw=true) + +## Setup the SwiftUI LiveView + +The [ContentView](https://developer.apple.com/tutorials/swiftui-concepts/exploring-the-structure-of-a-swiftui-app#Content-view) contains the main view of our iOS application. + +Replace the code in the `ContentView` file with the following to connect the SwiftUI application and the Phoenix application. + + + +```swift +import SwiftUI +import LiveViewNative + +struct ContentView: View { + + var body: some View { + LiveView(.automatic( + development: .localhost(path: "/"), + production: .custom(URL(string: "https://example.com/")!) + )) + } +} + + +// Optionally preview the native UI in Xcode +#Preview { + ContentView() +} +``` + + + +The code above sets up the SwiftUI LiveView. By default, the SwiftUI LiveView connects to any Phoenix app running on http://localhost:4000. + + + + + +```mermaid +graph LR; + subgraph I[iOS App] + direction TB + ContentView + SL[SwiftUI LiveView] + end + subgraph P[Phoenix App] + LiveView + end + SL --> P + ContentView --> SL + + +``` + +## Start the Active Scheme + +Click the `start active scheme` button to build the project and run it on the iOS simulator. + +> A [build scheme](https://developer.apple.com/documentation/xcode/build-system) contains a list of targets to build, and any configuration and environment details that affect the selected action. For example, when you build and run an app, the scheme tells Xcode what launch arguments to pass to the app. +> +> * https://developer.apple.com/documentation/xcode/build-system + +After you start the active scheme, the simulator should open the iOS application and display `Hello from LiveView Native!`. If you encounter any issues see the **Troubleshooting** section below. + + + +
+ +
+ +## Troubleshooting + +If you encountered any issues with the native application, here are some troubleshooting steps you can use: + +* **Reset Package Caches:** In the Xcode application go to `File -> Packages -> Reset Package Caches`. +* **Update Packages:** In the Xcode application go to `File -> Packages -> Update to Latest Package Versions`. +* **Rebuild the Active Scheme**: In the Xcode application, press the `start active scheme` button to rebuild the active scheme and run it on the Xcode simulator. +* Update your [Xcode](https://developer.apple.com/xcode/) version if it is not already the latest version +* Check for error messages in the Livebook smart cells. + +You can also [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) if you would like support from the LiveView Native team. diff --git a/guides/markdown_livebooks/forms-and-validation.md b/guides/markdown_livebooks/forms-and-validation.md new file mode 100644 index 000000000..828e14a9c --- /dev/null +++ b/guides/markdown_livebooks/forms-and-validation.md @@ -0,0 +1,640 @@ +# Forms and Validation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%forms-and-validation.livemd) + +## Overview + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) project makes it easier to build forms in LiveView Native. This project enables you to group different [Control Views](https://developer.apple.com/documentation/swiftui/controls-and-indicators) inside of a `LiveForm` and control them collectively under a single `phx-change` or `phx-submit` event handler, rather than with multiple different `phx-change` event handlers. + +Getting the most out of this material requires some understanding of the [Ecto](https://hexdocs.pm/ecto/Ecto.html) project and in particular a reasonably deep understanding of [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). Review the linked Ecto documentation if you find any of the examples difficult to follow. + +## Installing LiveView Native Live Form + +To install LiveView Native Form, we need to add the `liveview-native-live-form` SwiftUI package to our iOS application. + +Follow the [LiveView Native Form Installation Guide](https://github.com/liveview-native/liveview-native-live-form?tab=readme-ov-file#liveviewnativeliveform) on that project's README and come back to this guide after you have finished the installation process. + +## Creating a Basic Form + +Once you have the LiveView Native Form package installed, you can use the `LiveForm` and `LiveSubmitButton` views to build forms more conveniently. + +Here's a basic example of a `LiveForm`. Keep in mind that `LiveForm` requires an `id` attribute. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.ExampleLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +When a form is submitted, its data is sent as a map where each key is the 'name' attribute of the form's control views. Evaluate the example above in your simulator and you will see a map similar to the following: + + + +```elixir +%{"my-text" => "some value"} +``` + +In a real-world application you could use these params to trigger some application logic, such as inserting a record into the database. + +## Controls and Indicators + +We've already covered many individual controls and indicator views that you can use inside of forms. For more information on those, go to the [Interactive SwiftUI Views](https://hexdocs.pm/live_view_native/interactive-swiftui-views.html) guide. + + + +### Your Turn + +Create a form that has `TextField`, `Slider`, `Toggle`, and `DatePicker` fields. + +### Example Solution + +```elixir +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Placeholder + + + + Submit + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +``` + + + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.MultiInputFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + # You may use this handler to test your solution. + # You should not need to modify this handler. + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Controlled Values + +Some control views such as the `Stepper` require manually displaying their value. In this case, we can store the form params in the socket and update them everytime the `phx-change` form binding submits an event. You can also use this pattern to provide default values. + +Evaluate the example below to see this in action. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.StepperLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, params: %{"my-stepper" => 1})} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + <%= @params["my-stepper"] %> + + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, assign(socket, params: params)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +### Secure Field + +For password entry, or anytime you want to hide a given value, you can use the [SecureField](https://developer.apple.com/documentation/swiftui/securefield) view. This field works mostly the same as a `TextField` but hides the visual text. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.SecureLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + Enter a Password + """ + end + + @impl true + def handle_event("change", params, socket) do + IO.inspect(params) + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Keyboard Types + +To format a `TextField` for specific input types we can use the [keyboardType](https://developer.apple.com/documentation/swiftui/view/keyboardtype(_:)) modifier. + +For a complete list of accepted keyboard types, see the [UIKeyboardType](https://developer.apple.com/documentation/uikit/uikeyboardtype) documentation. + +Below we've created several different common keyboard types. We've also included a generic `keyboard-*` to demonstrate how you can make a reusable class. + +```elixir +defmodule KeyboardStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "number-pad" do + keyboardType(.numberPad) + end + + "email-address" do + keyboardType(.emailAddress) + end + + "phone-pad" do + keyboardType(.phonePad) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +Evaluate the example below to see the different keyboards as you focus on each input. If you don't see the keyboard, go to `I/O` -> `Keyboard` -> `Toggle Software Keyboard` to enable the software keyboard in your simulator. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.KeyboardLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use KeyboardStylesheet + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter Phone + Enter Number + Enter Number + """ + end + + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +## Validation + +In this section, we'll focus mainly on using [Ecto Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) to validate data, but know that this is not the only way to validate data if you would like to write your own custom logic in the form event handlers, you absolutely can. + + + +### LiveView Native Changesets Coming Soon! + +LiveView Native Form doesn't currently natively support [Changesets](https://hexdocs.pm/ecto/Ecto.Changeset.html) and [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) structs the way a traditional [Phoenix.Component.form](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#form/1) does. However there is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/5) to add this behavior so this may change in the near future. As a result, this section is somewhat more verbose than will be necessary in the future, as we have to manually define much of the error handling logic that we expect will no longer be necessary in version `0.3` of LiveView Native. + +To make error handling easier, we've defined an `ErrorUtils` module below that will handle extracting the error message out of a Changeset. This will not be necessary in future versions of LiveView Native, but is a convenient helper for now. + +```elixir +defmodule ErrorUtils do + def error_message(errors, field) do + with {msg, opts} <- errors[field] do + Server.CoreComponents.translate_error({msg, opts}) + else + _ -> "" + end + end +end +``` + +For the sake of context, the `translate_message/2` function handles formatting Ecto Changeset errors. For example, it will inject values such as `count` into the string. + +```elixir +Server.CoreComponents.translate_error( + {"name must be longer than %{count} characters", [count: 10]} +) +``` + +### Changesets + +Here's a `User` changeset we're going to use to validate a `User` struct's `email` field. + +```elixir +defmodule User do + import Ecto.Changeset + defstruct [:email] + @types %{email: :string} + + def changeset(user, params) do + {user, @types} + |> cast(params, [:email]) + |> validate_required([:email]) + |> validate_format(:email, ~r/@/) + end +end +``` + +We're going to define an `error` class so errors will appear red and be left-aligned. + +```elixir +defmodule ErrorStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + """ +end +``` + +Then, we're going to create a LiveView that uses the `User` changeset to validate data. + +Evaluate the example below and view it in your simulator. We've included and `IO.inspect/2` call to view the changeset after submitting the form. Try submitting the form with different values to understand how those values affect the changeset. + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.FormValidationLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use ErrorStylesheet + + @impl true + def mount(_params, _session, socket) do + user_changeset = User.changeset(%User{}, %{}) + {:ok, assign(socket, :user_changeset, user_changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + Enter your email + + <%= ErrorUtils.error_message(@user_changeset.errors, :email) %> + + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # Preserve the `:action` field so errors do not vanish. + |> Map.put(:action, socket.assigns.user_changeset.action) + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + User.changeset(%User{}, params) + # faking a Database insert action + |> Map.put(:action, :insert) + # Submit the form and inspect the logs below to view the changeset. + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, :user_changeset, user_changeset)} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` + +In the code above, the `"sumbit"` and `"validate"` events update the changeset based on the current form params. This fills the `errors` field used by the `ErrorUtils` module to format the error message. + +After submitting the form, the `:action` field of the changeset has a value of `:insert`, so the red Text appears using the `:if` conditional display logic. + +In the future, this complexity will likely be handled by the `live_view_native_form` library, but for now this example exists to show you how to write your own error handling based on changesets if needed. + + + +### Empty Fields Send `"null"`. + +If you submit a form with empty fields, those fields may currently send `"null"`. There is an [open issue](https://github.com/liveview-native/liveview-native-live-form/issues/6) to fix this bug, but it may affect your form behavior for now and require a temporary workaround until the issue is fixed. + +## Mini Project: User Form + +Taking everything you've learned, you're going to create a more complex user form with data validation and error displaying. We've defined a `FormStylesheet` you can use (and modify) if you would like to style your form. + +```elixir +defmodule FormStylesheet do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "error" do + foregroundStyle(.red) + frame(maxWidth: .infinity, alignment: .leading) + end + + "keyboard-" <> type do + keyboardType(to_ime(type)) + end + """ +end +``` + +### User Changeset + +First, create a `CustomUser` changeset below that handles data validation. + +**Requirements** + +* A user should have a `name` field +* A user should have a `password` string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn't have a database. In real-world apps passwords should **never** be stored as a simple string, they should be encrypted. +* A user should have an `age` number field greater than `0` and less than `200`. +* A user should have an `email` field which matches an email format (including `@` is sufficient). +* A user should have a `accepted_terms` field which must be true. +* A user should have a `birthdate` field which is a date. +* All fields should be required + +### Example Solution + +```elixir +defmodule CustomUser do + import Ecto.Changeset + defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate] + + @types %{ + name: :string, + password: :string, + age: :integer, + email: :string, + accepted_terms: :boolean, + birthdate: :date + } + + def changeset(user, params) do + {user, @types} + |> cast(params, Map.keys(@types)) + |> validate_required(Map.keys(@types)) + |> validate_length(:password, min: 10) + |> validate_number(:age, greater_than: 0, less_than: 200) + |> validate_acceptance(:accepted_terms) + end + + def error_message(changeset, field) do + with {msg, _reason} <- changeset.errors[field] do + msg + else + _ -> "" + end + end +end +``` + + + +```elixir +defmodule CustomUser do + # define the struct keys + defstruct [] + + # define the types + @types %{} + + def changeset(user, params) do + # Enter your solution + end +end +``` + +### LiveView + +Next, create the `CustomUserFormLive` Live View that lets the user enter their information and displays errors for invalid information upon form submission. + +**Requirements** + +* The `name` field should be a `TextField`. +* The `email` field should be a `TextField`. +* The `password` field should be a `SecureField`. +* The `age` field should be a `TextField` with a `.numberPad` keyboard or a `Slider`. +* The `accepted_terms` field should be a `Toggle`. +* The `birthdate` field should be a `DatePicker`. + +### Example Solution + +```elixir +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + changeset = CustomUser.changeset(%CustomUser{}, %{}) + + {:ok, assign(socket, :changeset, changeset)} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + name... + <.form_error changeset={@changeset} field={:name}/> + + email... + <.form_error changeset={@changeset} field={:email}/> + + age... + <.form_error changeset={@changeset} field={:age}/> + + password... + <.form_error changeset={@changeset} field={:password}/> + + Accept the Terms and Conditions: + <.form_error changeset={@changeset} field={:accepted_terms}/> + + Birthday: + <.form_error changeset={@changeset} field={:birthdate}/> + Submit + + """ + end + + @impl true + def handle_event("validate", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, socket.assigns.changeset.action) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + def handle_event("submit", params, socket) do + user_changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, :changeset, user_changeset)} + end + + # While not strictly required, the form_error component reduces code bloat. + def form_error(assigns) do + ~SWIFTUI""" + + <%= CustomUser.error_message(@changeset, @field) %> + + """ + end +end +``` + + + + + +```elixir +require KinoLiveViewNative.Livebook +import KinoLiveViewNative.Livebook +import Kernel, except: [defmodule: 2] + +defmodule Server.CustomUserFormLive do + use Phoenix.LiveView + use LiveViewNative.LiveView + use FormStylesheet + + @impl true + def mount(_params, _session, socket) do + # Remember to provide the initial changeset + {:ok, socket} + end + + @impl true + def render(%{format: :swiftui} = assigns) do + ~SWIFTUI""" + + """ + end + + @impl true + # Write your `"validate"` event handler + def handle_event("validate", params, socket) do + {:noreply, socket} + end + + # Write your `"submit"` event handler + def handle_event("submit", params, socket) do + {:noreply, socket} + end +end +|> KinoLiveViewNative.register("/", ":index") + +import KinoLiveViewNative.Livebook, only: [] +import Kernel +:ok +``` diff --git a/guides/markdown_livebooks/getting-started.md b/guides/markdown_livebooks/getting-started.md new file mode 100644 index 000000000..ecd39aa8b --- /dev/null +++ b/guides/markdown_livebooks/getting-started.md @@ -0,0 +1,87 @@ +# Getting Started + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%getting-started.livemd) + +## Overview + +Our livebook guides provide step-by-step lessons to help you learn LiveView Native using Livebook. These guides assume that you already have some familiarity with Phoenix LiveView applications. + +You can read these guides online, or for the best experience we recommend you click on the "Run in Livebook" badge to import and run these guides locally with Livebook. + +Each guide can be completed independently, but we suggest following them chronologically for the most comprehensive learning experience. + +## Prerequisites + +To use these guides, you'll need to install the following prerequisites: + +* [Elixir/Erlang](https://elixir-lang.org/install.html) +* [Livebook](https://livebook.dev/) +* [Xcode](https://developer.apple.com/xcode/) + +While not necessary for our guides, we also recommend you install the following for general LiveView Native development: + +* [Phoenix](https://hexdocs.pm/phoenix/installation.html) +* [PostgreSQL](https://www.postgresql.org/download/) +* [LiveView Native VS Code Extension](https://github.com/liveview-native/liveview-native-vscode) + +## Hello World + +If you are not already running this guide in Livebook, click on the "Run in Livebook" badge at the top of this page to import this guide into Livebook. + +Then, you can evaluate the following smart cell and visit http://localhost:4000 to ensure this Livebook works correctly. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +In an upcoming lesson, you'll set up an iOS application with Xcode so you can run code native examples. + +## Your Turn: Live Reloading + +Change `Hello from LiveView!` to `Hello again from LiveView!` in the above LiveView. Re-evaluate the cell and notice the application live reloads and automatically updates in the browser. + +## Kino LiveView Native + +To run a Phoenix Server setup with LiveView Native from within Livebook we built the [Kino LiveView Native](https://github.com/liveview-native/kino_live_view_native) library. + +Whenever you run one of our Livebooks, a server starts on localhost:4000. Ensure you have no other servers running on port 4000 + +Kino LiveView Native defines the **LiveView Native: LiveView** and **LiveViewNative: Render Component** smart cells within these guides. + +## Troubleshooting + +Some common issues you may encounter are: + +* Another server is already running on port 4000. +* Your version of Livebook needs to be updated. +* Your version of Elixir/Erlang needs to be updated. +* Your version of Xcode needs to be updated. +* This Livebook has cached outdated versions of dependencies + +Ensure you have the latest versions of all necessary software installed, and ensure no other servers are running on port 4000. + +To clear the cache, you can click the `Setup without cache` button revealed by clicking the dropdown next to the `setup` button at the top of the Livebook. + +If that does not resolve the issue, you can [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) to receive support from the LiveView Native team. diff --git a/guides/markdown_livebooks/interactive-swiftui-views.md b/guides/markdown_livebooks/interactive-swiftui-views.md new file mode 100644 index 000000000..95e34addb --- /dev/null +++ b/guides/markdown_livebooks/interactive-swiftui-views.md @@ -0,0 +1,756 @@ +# Interactive SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%interactive-swiftui-views.livemd) + +## Overview + +In this guide, you'll learn how to build interactive LiveView Native applications using event bindings. + +This guide assumes some existing familiarity with [Phoenix Bindings](https://hexdocs.pm/phoenix_live_view/bindings.html) and how to set/access state stored in the LiveView's socket assigns. To get the most out of this material, you should already understand the `assign/3`/`assign/2` function, and how event bindings such as `phx-click` interact with the `handle_event/3` callback function. + +We'll use the following LiveView and define new render component examples throughout the guide. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Event Bindings + +We can bind any available `phx-*` [Phoenix Binding](https://hexdocs.pm/phoenix_live_view/bindings.html) to a SwiftUI Element. However certain events are not available on native. + +LiveView Native currently supports the following events on all SwiftUI views: + +* `phx-window-focus`: Fired when the application window gains focus, indicating user interaction with the Native app. +* `phx-window-blur`: Fired when the application window loses focus, indicating the user's switch to other apps or screens. +* `phx-focus`: Fired when a specific native UI element gains focus, often used for input fields. +* `phx-blur`: Fired when a specific native UI element loses focus, commonly used with input fields. +* `phx-click`: Fired when a user taps on a native UI element, enabling a response to tap events. + +> The above events work on all SwiftUI views. Some events are only available on specific views. For example, `phx-change` is available on controls and `phx-throttle/phx-debounce` is available on views with events. + +There is also a [Pull Request](https://github.com/liveview-native/liveview-client-swiftui/issues/1095) to add Key Events which may have been merged since this guide was published. + +## Basic Click Example + +The `phx-click` event triggers a corresponding [handle_event/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_event/3) callback function whenever a SwiftUI view is pressed. + +In the example below, the client sends a `"ping"` event to the server, and trigger's the LiveView's `"ping"` event handler. + +Evaluate the example below, then click the `"Click me!"` button. Notice `"Pong"` printed in the server logs below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("ping", _params, socket) do + IO.puts("Pong") + {:noreply, socket} + end +end +``` + +### Click Events Updating State + +Event handlers in LiveView can update the LiveView's state in the socket. + +Evaluate the cell below to see an example of incrementing a count. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end +end +``` + +### Your Turn: Decrement Counter + +You're going to take the example above, and create a counter that can **both increment and decrement**. + +There should be two buttons, each with a `phx-click` binding. One button should bind the `"decrement"` event, and the other button should bind the `"increment"` event. Each event should have a corresponding handler defined using the `handle_event/3` callback function. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("decrement", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + <%= @count %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Selectable Lists + +`List` views support selecting items within the list based on their id. To select an item, provide the `selection` attribute with the item's id. + +Pressing a child item in the `List` on a native device triggers the `phx-change` event. In the example below we've bound the `phx-change` event to send the `"selection-changed"` event. This event is then handled by the `handle_event/3` callback function and used to change the selected item. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Item <%= i %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, selection: "None")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + +## Expandable Lists + +`List` views support hierarchical content using the [DisclosureGroup](https://developer.apple.com/documentation/swiftui/disclosuregroup) view. Nest `DisclosureGroup` views within a list to create multiple levels of content as seen in the example below. + +To control a `DisclosureGroup` view, use the `is-expanded` boolean attribute as seen in the example below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + Item 2 + Item 3 + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :is_expanded, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-expanded" => is_expanded}, socket) do + {:noreply, assign(socket, is_expanded: !is_expanded)} + end +end +``` + +### Multiple Expandable Lists + +The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Level 1 + Item 1 + + Level 2 + Item 2 + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle-" <> level, %{"is-expanded" => is_expanded}, socket) do + level = String.to_integer(level) + + {:noreply, + assign( + socket, + :expanded_groups, + Map.replace!(socket.assigns.expanded_groups, level, !is_expanded) + )} + end +end +``` + +## Controls and Indicators + +In Phoenix, the `phx-change` event must be applied to a parent form. However in SwiftUI there is no similar concept of forms. Instead, SwiftUI provides [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) views. We can apply the `phx-change` binding to any of these views. + +Once bound, the SwiftUI view will send a message to the LiveView anytime the control or indicator changes its value. + +The params of the message are based on the name of the [Binding](https://developer.apple.com/documentation/swiftui/binding) argument of the view's initializer in SwiftUI. + + + +### Event Value Bindings + +Many views use the `value` binding argument, so event params are generally sent as `%{"value" => value}`. However, certain views such as `TextField` and `Toggle` deviate from this pattern because SwiftUI uses a different `value` binding argument. For example, the `TextField` view uses `text` to bind its value, so it sends the event params as `%{"text" => value}`. + +When in doubt, you can connect the event handler and inspect the params to confirm the shape of map. + +## Text Field + +The following example shows you how to connect a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) with a `phx-change` event binding to a corresponding event handler. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the server logs in the console below as a map of `%{"text" => value}`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", params, socket) do + IO.inspect(params, label: "params") + {:noreply, socket} + end +end +``` + +### Storing TextField Values in the Socket + +The following example demonstrates how to set/access a TextField's value by controlling it using the socket assigns. + +This pattern is useful when rendering the TextField's value elsewhere on the page, using the `TextField` view's value in other event handler logic, or to set an initial value. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Enter text here + + The current value: <%= @text %> + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :text, "initial value")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type", %{"text" => text}, socket) do + {:noreply, assign(socket, :text, text)} + end + + @impl true + def handle_event("pretty-print", _params, socket) do + IO.puts(""" + ================== + #{socket.assigns.text} + ================== + """) + + {:noreply, socket} + end +end +``` + +## Slider + +This code example renders a SwiftUI [Slider](https://developer.apple.com/documentation/swiftui/slider). It triggers the change event when the slider is moved and sends a `"slide"` message. The `"slide"` event handler then logs the value to the console. + +Evaluate the example and enter some text in your iOS simulator. Notice the inspected `params` appear in the console below as a map of `%{"value" => value}`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Percent Completed + 0% + 100% + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("slide", params, socket) do + IO.inspect(params, label: "Slide Params") + {:noreply, socket} + end +end +``` + +## Stepper + +This code example renders a SwiftUI [Stepper](https://developer.apple.com/documentation/swiftui/stepper). It triggers the change event and sends a `"change-tickets"` message when the stepper increments or decrements. The `"change-tickets"` event handler then updates the number of tickets stored in state, which appears in the UI. + +Evaluate the example and increment/decrement the step. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + Tickets <%= @tickets %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :tickets, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change-tickets", %{"value" => tickets}, socket) do + {:noreply, assign(socket, :tickets, tickets)} + end +end +``` + +## Toggle + +This code example renders a SwiftUI [Toggle](https://developer.apple.com/documentation/swiftui/toggle). It triggers the change event and sends a `"toggle"` message when toggled. The `"toggle"` event handler then updates the `:on` field in state, which allows the `Toggle` view to be toggled on. Without providing the `is-on` attribute, the `Toggle` view could not be flipped on and off. + +Evaluate the example below and click on the toggle. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + On/Off + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :on, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"is-on" => on}, socket) do + {:noreply, assign(socket, :on, on)} + end +end +``` + +## DatePicker + +The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :date, nil)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", params, socket) do + IO.inspect(params, label: "Date Params") + {:noreply, socket} + end +end +``` + +### Parsing Dates + +The date from the `DatePicker` is in iso8601 format. You can use the `from_iso8601` function to parse this string into a `DateTime` struct. + +```elixir +iso8601 = "2024-01-17T20:51:00.000Z" + +DateTime.from_iso8601(iso8601) +``` + +### Your Turn: Displayed Components + +The `DatePicker` view accepts a `displayed-components` attribute with the value of `"hour-and-minute"` or `"date"` to only display one of the two components. By default, the value is `"all"`. + +You're going to change the `displayed-components` attribute in the example below to see both of these options. Change `"all"` to `"date"`, then to `"hour-and-minute"`. Re-evaluate the cell between changes and see the updated UI. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + def handle_event("pick-date", params, socket) do + {:noreply, socket} + end +end +``` + +## Small Project: Todo List + +Using the previous examples as inspiration, you're going to create a todo list. + +**Requirements** + +* Items should be `Text` views rendered within a `List` view. +* Item ids should be stored in state as a list of integers i.e. `[1, 2, 3, 4]` +* Use a `TextField` to provide the name of the next added todo item. +* An add item `Button` should add items to the list of integers in state when pressed. +* A delete item `Button` should remove the currently selected item from the list of integers in state when pressed. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Todo... + + + + <%= content %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type-name", %{"text" => name}, socket) do + {:noreply, assign(socket, :item_name, name)} + end + + def handle_event("add-item", _params, socket) do + updated_items = [ + {"item-#{socket.assigns.next_item_id}", socket.assigns.item_name} + | socket.assigns.items + ] + + {:noreply, + assign(socket, + item_name: "", + items: updated_items, + next_item_id: socket.assigns.next_item_id + 1 + )} + end + + def handle_event("delete-item", _params, socket) do + updated_items = + Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end) + {:noreply, assign(socket, :items, updated_items)} + end + + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + # Define your mount/3 callback + + @impl true + def render(assigns), do: ~H"" + + # Define your render/3 callback + + # Define any handle_event/3 callbacks +end +``` diff --git a/guides/markdown_livebooks/native-navigation.md b/guides/markdown_livebooks/native-navigation.md new file mode 100644 index 000000000..52746e1df --- /dev/null +++ b/guides/markdown_livebooks/native-navigation.md @@ -0,0 +1,303 @@ +# Native Navigation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%native-navigation.livemd) + +## Overview + +This guide will teach you how to create multi-page applications using LiveView Native. We will cover navigation patterns specific to native applications and how to reuse the existing navigation patterns available in LiveView. + +Before diving in, you should have a basic understanding of navigation in LiveView. You should be familiar with the [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions, which are used to trigger navigation from within a LiveView. Additionally, you should know how to define routes in the router using the [live/4](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4) macro. + +## NavigationStack + +LiveView Native applications are generally wrapped in a [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view. This view usually exists in the `root.swiftui.heex` file, which looks something like the following: + + + +```elixir +<.csrf_token /> + + +
+ Hello, from LiveView Native! +
+
+``` + +Notice the [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view wraps the template. This view manages the state of navigation history and allows for navigating back to previous pages. + +## Navigation Links + +We can use the [NavigationLink](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/navigationlink) view for native navigation, similar to how we can use the [.link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with the `navigate` attribute for web navigation. + +We've created the same example of navigating between the `Main` and `About` pages. Each page using a `NavigationLink` to navigate to the other page. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + To Home + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + + + +```elixir +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the main page + + To About + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +The `destination` attribute works the same as the `navigate` attribute on the web. The current LiveView will shut down, and a new one will mount without re-establishing a new socket connection. + +## Push Navigation + +For LiveView Native views, we can still use the same [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2), and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions used in typical LiveViews. + +These functions are preferable over `NavigationLink` views when you want to share navigation handlers between web and native, and/or when you want to have more customized navigation handling. + +Evaluate **both** of the code cells below and click on the `Button` view in your simulator that triggers the `handle_event/3` navigation handler to navigate between the two views. + + + +```elixir +defmodule ServerWeb.MainLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the Main Page + + """ + end +end + +defmodule ServerWeb.MainLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-about", _params, socket) do + {:noreply, push_navigate(socket, to: "/about")} + end +end +``` + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the About Page + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-main", _params, socket) do + {:noreply, push_navigate(socket, to: "/")} + end +end +``` + +## Routing + +The `KinoLiveViewNative` smart cells used in this guide automatically define routes for us. Be aware there is no difference between how we define routes for LiveView or LiveView Native. + +The routes for the main and about pages might look like the following in the router: + + + +```elixir +live "/", Server.MainLive +live "/about", Server.AboutLive +``` + +## Native Navigation Events + +LiveView Native navigation mirrors the same navigation behavior you'll find on the web. + +Evaluate the example below and press each button. Notice that: + +1. `redirect/2` triggers the `mount/3` callback re-establishes a socket connection. +2. `push_navigate/2` triggers the `mount/3` callbcak and re-uses the existing socket connection. +3. `push_patch/2` does not trigger the `mount/3` callback, but does trigger the `handle_params/3` callback. This is often useful when using navigation to trigger page changes such as displaying a modal or overlay. + +You can see this for yourself using the following example. Click each of the buttons for redirect, navigate, and patch behavior. + + + +```elixir +# This module built for example purposes to persist logs between mounting LiveViews. +defmodule PersistantLogs do + def get do + :persistent_term.get(:logs) + end + + def put(log) when is_binary(log) do + :persistent_term.put(:logs, [{log, Time.utc_now()} | get()]) + end + + def reset do + :persistent_term.put(:logs, []) + end +end + +PersistantLogs.reset() + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + + + Socket ID<%= @socket_id %> + LiveView PID:<%= @live_view_pid %> + <%= for {log, time} <- Enum.reverse(@logs) do %> + + <%= Calendar.strftime(time, "%H:%M:%S") %>: + <%= log %> + + <% end %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + PersistantLogs.put("MOUNT") + + {:ok, + assign(socket, + socket_id: socket.id, + connected: connected?(socket), + logs: PersistantLogs.get(), + live_view_pid: inspect(self()) + )} + end + + @impl true + def handle_params(_params, _url, socket) do + PersistantLogs.put("HANDLE PARAMS") + + {:noreply, assign(socket, :logs, PersistantLogs.get())} + end + + @impl true + def render(assigns), + do: ~H"" + + @impl true + def handle_event("redirect", _params, socket) do + PersistantLogs.reset() + PersistantLogs.put("--REDIRECTING--") + {:noreply, redirect(socket, to: "/")} + end + + def handle_event("navigate", _params, socket) do + PersistantLogs.put("---NAVIGATING---") + {:noreply, push_navigate(socket, to: "/")} + end + + def handle_event("patch", _params, socket) do + PersistantLogs.put("----PATCHING----") + {:noreply, push_patch(socket, to: "/")} + end +end +``` diff --git a/guides/markdown_livebooks/stylesheets.md b/guides/markdown_livebooks/stylesheets.md new file mode 100644 index 000000000..fa99bdf4a --- /dev/null +++ b/guides/markdown_livebooks/stylesheets.md @@ -0,0 +1,538 @@ +# Stylesheets + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%stylesheets.livemd) + +## Overview + +In this guide, you'll learn how to use stylesheets to customize the appearance of your LiveView Native Views. You'll also learn about the inner workings of how LiveView Native uses stylesheets to implement modifiers, and how those modifiers style and customize SwiftUI Views. By the end of this lesson, you'll have the fundamentals you need to create beautiful native UIs. + +## The Stylesheet AST + +LiveView Native parses through your application at compile time to create a stylesheet AST representation of all the styles in your application. This stylesheet AST is used by the LiveView Native Client application when rendering the view hierarchy to apply modifiers to a given view. + +```mermaid +sequenceDiagram + LiveView->>LiveView: Create stylesheet + Client->>LiveView: Send request to "http://localhost:4000/?_format=swiftui" + LiveView->>Client: Send LiveView Native template in response + Client->>LiveView: Send request to "http://localhost:4000/assets/app.swiftui.styles" + LiveView->>Client: Send stylesheet in response + Client->>Client: Parses stylesheet into SwiftUI modifiers + Client->>Client: Apply modifiers to the view hierarchy +``` + +We've setup this Livebook to be included when parsing the application for modifiers. You can visit http://localhost:4000/assets/app.swiftui.styles to see the Stylesheet AST created by all of the styles in this Livebook and any other styles used in the `kino_live_view_native` project. + +LiveView Native watches for changes and updates the stylesheet, so those will be dynamically picked up and applied, You may notice a slight delay as the Livebook takes **5 seconds** to write it's contents to a file. + +## Modifiers + +SwiftUI employs **modifiers** to style and customize views. In SwiftUI syntax, each modifier is a function that can be chained onto the view they modify. LiveView Native has a minimal DSL (Domain Specific Language) for writing SwiftUI modifiers. + +Modifers can be applied through a LiveView Native Stylesheet and applying them through classes as described in the [LiveView Native Stylesheets](#liveview-native-stylesheets) section, or can be applied directly through the `class` attribute as described in the [Utility Styles](#utility-styles) section. + + + +### SwiftUI Modifiers + +Here's a basic example of making text red using the [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) modifier: + +```swift +Text("Some Red Text") + .foregroundStyle(.red) +``` + +Many modifiers can be applied to a view. Here's an example using [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) and [frame](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)). + +```swift +Text("Some Red Text") + .foregroundStyle(.red) + .font(.title) +``` + + + +### Implicit Member Expression + +Implicit Member Expression in SwiftUI means that we can implicityly access a member of a given type without explicitly specifying the type itself. For example, the `.red` value above is from the [Color](https://developer.apple.com/documentation/swiftui/color) structure. + +```swift +Text("Some Red Text") + .foregroundStyle(Color.red) +``` + + + +### LiveView Native Modifiers + +The DSL (Domain Specific Language) used in LiveView Native drops the `.` dot before each modifier, but otherwise remains largely the same. We do not document every modifier separately, since you can translate SwiftUI examples into the DSL syntax. + +For example, Here's the same `foregroundStyle` modifier as it would be written in a LiveView Native stylesheet or class attribute, which we'll cover in a moment. + +```swift +foregroundStyle(.red) +``` + +There are some exceptions where the DSL differs from SwiftUI syntax, which we'll cover in the sections below. + +## Utility Styles + +In addition to introducing stylesheets, LiveView Native `0.3.0` also introduced Utility classes, which will be our prefered method for writing styles in these Livebook guides. + +The same SwiftUI syntax used inside of a stylesheet can be used directly inside of a `class` attribute. The example below defines the `foregroundStyle(.red)` modifier. Evaluate the example and view it in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Multiple Modifiers + +You can write multiple modifiers, separate each by a space or newline character. + +```html +Hello, from LiveView Native! +``` + +For newline characters, you'll need to wrap the string in curly brackets `{}`. Using multiple lines can better organize larger amounts of modifiers. + +```html + +Hello, from LiveView Native! + +``` + +## Dynamic Class Names + +LiveView Native parses styles in your project to define a single stylesheet. You can find the AST representation of this stylesheet at http://localhost:4000/assets/app.swiftui.styles. This stylesheet is compiled on the server and then sent to the client. For this reason, class names must be fully-formed. For example, the following class using string interpolation is **invalid**. + +```html + +Invalid Example + +``` + +However, we can still use dynamic styles so long as the class names are fully formed. + +```html + +Red or Blue Text + +``` + +Evaluate the example below multiple times while watching your simulator. Notice that the text is dynamically red or blue. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Modifier Order + +Modifier order matters. Changing the order that modifers are applied can have a significant impact on their behavior. + +To demonstrate this concept, we're going to take a simple example of applying padding and background color. + +If we apply the background color first, then the padding, The background is applied to original view, leaving the padding filled with whitespace. + + + +```elixir +background(.orange) +padding(20) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style View fill:orange +``` + +If we apply the padding first, then the background, the background is applied to the view with the padding, thus filling the entire area with background color. + + + +```elixir +padding(20) +background(.orange) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style Padding fill:orange +style View fill:orange +``` + +Evaluate the example below to see this in action. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Injecting Views in Stylesheets + +SwiftUI modifiers sometimes accept SwiftUI views as arguments. Here's an example using the `clipShape` modifier with a `Circle` view. + +```swift +Image("logo") + .clipShape(Circle()) +``` + +However, LiveView Native does not support using SwiftUI views directly within a stylesheet. Instead, we have a few alternative options in cases like this where we want to use a view within a modifier. + + + +### Using Members on a Given Type + +We can't use the [Circle](https://developer.apple.com/documentation/swiftui/circle) view directly. However, if you look at the [clipShape](https://developer.apple.com/documentation/swiftui/view/clipshape(_:style:)) documentation you'll notice it accepts the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. This type defines the [circle](https://developer.apple.com/documentation/swiftui/shape/circle) property which we can use since it's equivalent to the [Circle](https://developer.apple.com/documentation/swiftui/circle) view for our purposes. + +We can use `Shape.circle` instead of the `Circle` view. So, the following code is equivalent to the example above. + +```swift +Image("logo") + .clipShape(Shape.circle) +``` + +Using implicit member expression, we can simplify this code to the following: + +```swift +Image("logo") + .clipShape(.circle) +``` + +Which is simple to convert to the LiveView Native DSL using the rules we've already learned. + + + +```elixir +"example-class" do + clipShape(.circle) +end +``` + + + +### Injecting a View + +For more complex cases, we can inject a view directly into a stylesheet. + +Here's an example where this might be useful. SwiftUI has modifers that represent a named content area for views to be placed within. These views can even have their own modifiers, so it's not enough to use a simple static property on the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. + +```swift +Image("logo") + .overlay(content: { + Circle().stroke(.red, lineWidth: 4) + }) +``` + +To get around this issue, we instead inject a view into the stylesheet. First, define the modifier and use an atom to represent the view that's going to be injected. + + + +```elixir +"overlay-circle" do + overlay(content: :circle) +end +``` + +Then use the `template` attribute on the view to be injected into the stylesheet. This view should be a child of the view with the given class. + +```html + + + +``` + +We can then apply modifiers to the child view through a class as we've already seen. + +## Custom Colors + +### SwiftUI Color Struct + +The SwiftUI [Color](https://developer.apple.com/documentation/swiftui/color) structure accepts either the name of a color in the asset catalog or the RGB values of the color. + +Therefore we can define custom RBG styles like so: + +```swift +foregroundStyle(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) +``` + +Evaluate the example below to see the custom color in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Custom Colors in the Asset Catalogue + +Custom colors can be defined in the [Asset Catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Once defined in the asset catalogue of the Xcode application, the color can be referenced by name like so: + +```swift +foregroundStyle(Color("MyColor")) +``` + +Generally using the asset catalog is more performant and customizable than using custom RGB colors with the [Color](https://developer.apple.com/documentation/swiftui/color) struct. + + + +### Your Turn: Custom Colors in the Asset Catalog + +Custom colors can be defined in the asset catalog (https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Generat + +To create a new color go to the `Assets` folder in your iOS app and create a new color set. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-create-new-color-set.png?raw=true) + + + +To create a color set, enter the RGB values or a hexcode as shown in the image below. If you don't see the sidebar with color options, click the icon in the top-right of your Xcode app and click the **Show attributes inspector** icon shown highlighted in blue. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-modify-my-color.png?raw=true) + + + +The defined color is now available for use within LiveView Native styles. However, the app needs to be re-compiled to pick up a new color set. + +Re-build your SwiftUI Application before moving on. Then evaluate the code below. You should see your custom colored text in the simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## LiveView Native Stylesheets + +In LiveView Native, we use `~SHEET` sigil stylesheets to organize modifers by classes using an Elixir-oriented DSL similar to CSS for styling web elements. + +We group modifiers together within a class that can be applied to an element. Here's an example of how modifiers can be grouped into a "red-title" class in a stylesheet: + + + +```elixir +~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end +""" +``` + +We're mostly using Utility styles for these guides, but the stylesheet module does contain some important configuration to `@import` the utility styles module. It can also be used to group styles within a class if you have a set of modifiers you're repeatedly using and want to group together. + + + +```elixir +defmodule ServerWeb.Styles.App.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + @import LiveViewNative.SwiftUI.UtilityStyles + + ~SHEET""" + "red-title" do + foregroundColor(.red) + font(.title) + end + """ +end +``` + +Since the Phoenix server runs in a dependency for these guides, you don't have direct access to the stylesheet module. + +## Apple Documentation + +You can find documentation and examples of modifiers on [Apple's SwiftUI documentation](https://developer.apple.com/documentation/swiftui) which is comprehensive and thorough, though it may feel unfamiliar at first for Elixir Developers when compared to HexDocs. + + + +### Finding Modifiers + +The [Configuring View Elements](https://developer.apple.com/documentation/swiftui/view#configuring-view-elements) section of apple documentation contains links to modifiers organized by category. In that documentation you'll find useful references such as [Style Modifiers](https://developer.apple.com/documentation/swiftui/view-style-modifiers), [Layout Modifiers](https://developer.apple.com/documentation/swiftui/view-layout), and [Input and Event Modifiers](https://developer.apple.com/documentation/swiftui/view-input-and-events). + +You can also find the same modifiers with LiveView Native examples on the [LiveView Client SwiftUI Docs](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/paddingmodifier). + +## Visual Studio Code Extension + +If you use Visual Studio Code, we strongly recommend you install the [LiveView Native Visual Studio Code Extension](https://github.com/liveview-native/liveview-native-vscode) which provides autocompletion and type information thus making modifiers significantly easier to write and lookup. + +## Your Turn: Syntax Conversion + +Part of learning LiveView Native is learning SwiftUI. Fortunately we can leverage the existing SwiftUI ecosystem and convert examples into LiveView Native syntax. + +You're going to convert the following SwiftUI code into a LiveView Native template. This example is inspired by the official [SwiftUI Tutorials](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + +```elixir + VStack { + VStack(alignment: .leading) { + Text("Turtle Rock") + .font(.title) + HStack { + Text("Joshua Tree National Park") + Spacer() + Text("California") + } + .font(.subheadline) + + Divider() + + Text("About Turtle Rock") + .font(.title2) + Text("Descriptive text goes here") + } + .padding() + + Spacer() +} +``` + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Turtle Rock + + Joshua Tree National Park + + California + + + About Turtle Rock + Descriptive text goes here + + """ + end +end +``` + + + +Enter your solution below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end +``` diff --git a/guides/markdown_livebooks/swiftui-views.md b/guides/markdown_livebooks/swiftui-views.md new file mode 100644 index 000000000..6803a0906 --- /dev/null +++ b/guides/markdown_livebooks/swiftui-views.md @@ -0,0 +1,693 @@ +# SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Fguides%livebooks%swiftui-views.livemd) + +## Overview + +LiveView Native aims to use minimal SwiftUI code. All patterns for building interactive UIs are the same as LiveView. However, unlike LiveView for the web, LiveView Native uses SwiftUI templates to build the native UI. + +This lesson will teach you how to build SwiftUI templates using common SwiftUI views. We'll cover common uses of each view and give you practical examples you can use to build your own native UIs. This lesson is like a recipe book you can refer back to whenever you need an example of how to use a particular SwiftUI view. In addition, once you understand how to convert these views into the LiveView Native DSL, you should have the tools to convert essentially any SwiftUI View into the LiveView Native DSL. + +## Render Components + +LiveView Native `0.3.0` introduced render components to better encourage isolation of native and web templates and move away from co-location templates within the same LiveView module. + +Render components are namespaced under the main LiveView, and are responsible for defining the `render/1` callback function that returns the native template. + +For example, and `ExampleLive` LiveView module would have an `ExampleLive.SwiftUI` render component module for the native Template. + +This `ExampleLive.SwiftUI` render component may define a `render/1` callback function as seen below. + + + +```elixir +# Render Component +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +# LiveView +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +Throughout this and further material we'll re-define render components you can evaluate and see reflected in your Xcode iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from a LiveView Native Render Component! + """ + end +end +``` + +### Embedding Templates + +Alternatively, you may omit the render callback and instead define a `.neex` (Native + Embedded Elixir) template. + +By default, the module above would look for a template in the `swiftui/example_live*` path relative to the module's location. You can see the `LiveViewNative.Component` documentation for further explanation. + +For the sake of ease when working in Livebook, we'll prefer defining the `render/1` callback. However, we recommend you generally prefer template files when working locally in Phoenix LiveView Native projects. + +## SwiftUI Views + +In SwiftUI, a "View" is like a building block for what you see on your app's screen. It can be something simple like text or an image, or something more complex like a layout with multiple elements. Views are the pieces that make up your app's user interface. + +Here's an example `Text` view that represents a text element. + +```swift +Text("Hamlet") +``` + +LiveView Native uses the following syntax to represent the view above. + + + +```elixir +Hamlet +``` + +SwiftUI provides a wide range of Views that can be used in native templates. You can find a full reference of these views in the SwiftUI Documentation at https://developer.apple.com/documentation/swiftui/. You can also find a shorthand on how to convert SwiftUI syntax into the LiveView Native DLS in the [LiveView Native Syntax Conversion Cheatsheet](https://hexdocs.pm/live_view_native/cheatsheet.cheatmd). + +## Text + +We've already seen the [Text](https://developer.apple.com/documentation/swiftui/text) view, but we'll start simple to get the interactive tutorial running. + +Evaluate the cell below, then in Xcode, Start the iOS application you created in the [Create a SwiftUI Application](https://hexdocs.pm/live_view_native/create-a-swiftui-application.html) lesson and ensure you see the `"Hello, from LiveView Native!"` text. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end +``` + +## HStack and VStack + +SwiftUI includes many [Layout](https://developer.apple.com/documentation/swiftui/layout-fundamentals) container views you can use to arrange your user Interface. Here are a few of the most commonly used: + +* [VStack](https://developer.apple.com/documentation/swiftui/vstack): Vertically arranges nested views. +* [HStack](https://developer.apple.com/documentation/swiftui/hstack): Horizontally arranges nested views. + +Below, we've created a simple 3X3 game board to demonstrate how to use `VStack` and `HStack` to build a layout of horizontal rows in a single vertical column.o + +Here's a diagram to demonstrate how these rows and columns create our desired layout. + +```mermaid +flowchart +subgraph VStack + direction TB + subgraph H1[HStack] + direction LR + 1[O] --> 2[X] --> 3[X] + end + subgraph H2[HStack] + direction LR + 4[X] --> 5[O] --> 6[O] + end + subgraph H3[HStack] + direction LR + 7[X] --> 8[X] --> 9[O] + end + H1 --> H2 --> H3 +end +``` + +Evaluate the example below and view the working 3X3 layout in your Xcode simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +``` + +### Your Turn: 3x3 board using columns + +In the cell below, use `VStack` and `HStack` to create a 3X3 board using 3 columns instead of 3 rows as demonstrated above. The arrangement of `X` and `O` does not matter, however the content will not be properly aligned if you do not have exactly one character in each `Text` element. + +```mermaid +flowchart +subgraph HStack + direction LR + subgraph V1[VStack] + direction TB + 1[O] --> 2[X] --> 3[X] + end + subgraph V2[VStack] + direction TB + 4[X] --> 5[O] --> 6[O] + end + subgraph V3[VStack] + direction TB + 7[X] --> 8[X] --> 9[O] + end + V1 --> V2 --> V3 +end +``` + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + O + X + X + + + X + O + O + + + X + X + O + + + """ + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Grid + +`VStack` and `HStack` do not provide vertical-alignment between horizontal rows. Notice in the following example that the rows/columns of the 3X3 board are not aligned, just centered. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + X + X + + + X + O + O + + + X + O + + + """ + end +end +``` + +Fortunately, we have a few common elements for creating a grid-based layout. + +* [Grid](https://developer.apple.com/documentation/swiftui/grid): A grid that arranges its child views in rows and columns that you specify. +* [GridRow](https://developer.apple.com/documentation/swiftui/gridrow): A view that arranges its children in a horizontal line. + +A grid layout vertically and horizontally aligns elements in the grid based on the number of elements in each row. + +Evaluate the example below and notice that rows and columns are aligned. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + XX + X + X + + + X + X + + + X + X + X + + + """ + end +end +``` + +## List + +The SwiftUI [List](https://developer.apple.com/documentation/swiftui/list) view provides a system-specific interface, and has better performance for large amounts of scrolling elements. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + Item 1 + Item 2 + Item 3 + + """ + end +end +``` + +### Multi-dimensional lists + +Alternatively we can separate children within a `List` view in a `Section` view as seen in the example below. Views in the `Section` can have the `template` attribute with a `"header"` or `"footer"` value which controls how the content is displayed above or below the section. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + +
+ Header + Content + Footer +
+
+ """ + end +end +``` + +## ScrollView + +The SwiftUI [ScrollView](https://developer.apple.com/documentation/swiftui/scrollview) displays content within a scrollable region. ScrollView is often used in combination with [LazyHStack](https://developer.apple.com/documentation/swiftui/lazyvstack), [LazyVStack](https://developer.apple.com/documentation/swiftui/lazyhstack), [LazyHGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid), and [LazyVGrid](https://developer.apple.com/documentation/swiftui/lazyhgrid) to create scrollable layouts optimized for displaying large amounts of data. + +While `ScrollView` also works with typical `VStack` and `HStack` views, they are not optimal choices for large amounts of data. + + + +### ScrollView with VStack + +Here's an example using a `ScrollView` and a `HStack` to create scrollable text arranged horizontally. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +### ScrollView with HStack + +By default, the [axes](https://developer.apple.com/documentation/swiftui/scrollview/axes) of a `ScrollView` is vertical. To make a horizontal `ScrollView`, set the `axes` attribute to `"horizontal"` as seen in the example below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +### Optimized ScrollView with LazyHStack and LazyVStack + +`VStack` and `HStack` are inefficient for large amounts of data because they render every child view. To demonstrate this, evaluate the example below. You should experience lag when you attempt to scroll. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +To resolve the performance problem for large amounts of data, you can use the Lazy views. Lazy views only create items as needed. Items won't be rendered until they are present on the screen. + +The next example demonstrates how using `LazyVStack` instead of `VStack` resolves the performance issue. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + Item <%= n %> + + + """ + end +end +``` + +## Spacers + +[Spacers](https://developer.apple.com/documentation/swiftui/spacer) take up all remaining space in a container. + +![Apple Documentation](https://docs-assets.developer.apple.com/published/189fa436f07ed0011bd0c1abeb167723/Building-Layouts-with-Stack-Views-4@2x.png) + +> Image originally from https://developer.apple.com/documentation/swiftui/spacer + +Evaluate the following example and notice the `Text` element is pushed to the right by the `Spacer`. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + This text is pushed to the right + + """ + end +end +``` + +### Your Turn: Bottom Text Spacer + +In the cell below, use `VStack` and `Spacer` to place text in the bottom of the native view. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + Hello + + """ + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## AsyncImage + +`AsyncImage` is best for network images, or images served by the Phoenix server. + +Here's an example of `AsyncImage` with a lorem picsum image from https://picsum.photos/400/600. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Loading Spinner + +`AsyncImage` displays a loading spinner while loading the image. Here's an example of using `AsyncImage` without a URL so that it loads forever. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Relative Path + +For images served by the Phoenix server, LiveView Native evaluates URLs relative to the LiveView's host URL. This way you can use the path to static resources as you normally would in a Phoenix application. + +For example, the path `/images/logo.png` evaluates as http://localhost:4000/images/logo.png below. This serves the LiveView Native logo. + +Evaluate the example below to see the LiveView Native logo in the iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Image + +The `Image` element is best for system images such as the built in [SF Symbols](https://developer.apple.com/design/human-interface-guidelines/sf-symbols) or images placed into the SwiftUI [asset catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). + + + +### System Images + +You can use the `system-image` attribute to provide the name of system images to the `Image` element. + +For the full list of SF Symbols you can download Apple's [Symbols 5](https://developer.apple.com/sf-symbols/) application. + +Evaluate the cell below to see an example using the `square.and.arrow.up` symbol. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +### Your Turn: Asset Catalogue + +You can place assets in your SwiftUI application's asset catalogue. Using the asset catalogue for SwiftUI assets provide many benefits such as device-specific image variants, dark mode images, high contrast image mode, and improved performance. + +Follow this guide: https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs#Add-a-new-asset to create a new asset called Image. + +Then evaluate the following example and you should see this image in your simulator. For a convenient image, you can right-click and save the following LiveView Native logo. + +![LiveView Native Logo](https://github.com/liveview-native/documentation_assets/blob/main/logo.png?raw=true) + +You will need to **rebuild the native application** to pick up the changes to the assets catalogue. + + + +### Enter Your Solution Below + +You should not need to make changes to this cell. Set up an image in your asset catalogue named "Image", rebuild your native application, then evaluate this cell. You should see the image in your iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + """ + end +end +``` + +## Button + +A Button is a clickable SwiftUI View. + +The label of a button can be any view, such as a [Text](https://developer.apple.com/documentation/swiftui/text) view for text-only buttons or a [Label](https://developer.apple.com/documentation/swiftui/label) view for buttons with icons. + +Evaluate the example below to see the SwiftUI [Button](https://developer.apple.com/documentation/swiftui/button) element. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use LiveViewNative.Component, + format: :swiftui + + def render(assigns, _interface) do + ~LVN""" + + + """ + end +end +``` + +## Further Resources + +See the [SwiftUI Documentation](https://developer.apple.com/documentation/swiftui) for a complete list of SwiftUI elements and the [LiveView Native SwiftUI Documentation](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/) for LiveView Native examples of the SwiftUI elements. diff --git a/guides/syntax_conversion.cheatmd b/guides/syntax_conversion.cheatmd new file mode 100644 index 000000000..55ac7f30f --- /dev/null +++ b/guides/syntax_conversion.cheatmd @@ -0,0 +1,227 @@ +# SwiftUI to LiveView Native Conversion Cheat Sheet + +In this short guide, we'll cover the fundamental SwiftUI syntax you'll encounter in SwiftUI guides and documentation and how to convert that syntax into LiveView Native templates and stylesheets. We've omitted deeper explanations of each concept to keep this guide brief for use as a convenient cheat sheet. + +You may wish to bookmark this guide and return to it as needed. In the interest of quick reference, we've kept explanations short. We hope to provide more guides in the future that will help explain these concepts deeper. Stay tuned to the DockYard blog for more guides and subscribe to the [LiveView Native Newsletter](https://dockyard.com/newsletter) for the latest updates on LiveView Native development + +You can also find more documentation and guides on the [LiveView Native Hexdocs](https://hexdocs.pm/live_view_native/overview.html). + +## Views + +SwiftUI Views are the building blocks of user interfaces in Swift applications. They represent the visual elements of an app, such as buttons, text fields, and images, and are structured hierarchically to compose complex interfaces. In LiveView Native, we represent views using syntax similar to HTML tags. + +## + +{: .col-2} + +### SwiftUI + +```swift +Text("Hello, SwiftUI") +``` + +### LiveView Native + +```heex +Hello, SwiftUI +``` + +## Modifiers + +SwiftUI modifiers are functions used to modify the appearance, behavior, or layout of views declaratively. They enable developers to apply various transformations and adjustments to views, such as changing colors, fonts, sizes, and alignments or adding animations and gestures. These modifiers are chainable, allowing for complex and dynamic interfaces through multiple modifiers applied to a single view. + +In LiveView Native, we use stylesheets with the `class` attribute or the inline `style` attribute. To be more similar to CSS stylesheets, LiveView Native uses semi-colons `;` to split modifiers rather than the `.` used by SwiftUI. + +## + +{: .col-2} + +### SwiftUI + +```swift +Text("Hello, SwiftUI") + .font(.title) + .foregroundStyle(.blue) +``` + +### LiveView Native + +```elixir +Hello, SwiftUI +``` + +Spaces and using newline characters are optional to improve organization. + +```elixir +Hello, SwiftUI +``` + +## Attributes + +In SwiftUI, attributes are properties that define the appearance and behavior of views. Unlike modifiers, attributes set the initial properties of views, while modifiers dynamically modify or augment a view after it's created. Also, modifiers typically affect child views, whereas attributes only affect one view. In practice, attributes are more similar to parameters in a function, whereas modifiers are chainable functions that modify a view. + +## + +{: .col-2} + +### SwiftUI + +```swift +VStack(alignment: .leading) +``` + +### LiveView Native + +```heex + +``` + +## Unnamed Attributes + +In many SwiftUI Views, the first argument to the function is often an unnamed attribute. SwiftUI uses an underscore `_` to indicate the attribute is unnamed. Unnamed attributes are just optional syntax sugar to avoid passing in the name. + +In these cases, in LiveView Native, we use the attribute's name to provide the value. + +## + +{: .col-2} + +### SwiftUI + +Unnamed version + +```swift +Image("turtlerock") +``` + +Named version (equivalent to the above) + +```swift +Image(name: "turtlerock") +``` + +### LiveView Native + +```heex + +``` + +## + +### Finding the Attribute Name + +You can find the attributes to a view within the Topics section of the views documentation in the corresponding `init` definition. For example, here's the [Image Topics section](https://developer.apple.com/documentation/swiftui/image#creating-an-image) where you can find the [Image's init](https://developer.apple.com/documentation/swiftui/image/init(_:bundle:)) function definition. + +The init definition includes a `_ name` unnamed attribute whose value is a `String`. Here's the same snippet you can find in the documentation above. + +```swift +init( + _ name: String, + bundle: Bundle? = nil +) +``` + +## Views as Arguments + +SwiftUI Modifiers can accept views as arguments. Supporting views as arguments presents a challenge for LiveView Native as there's no equivalent in a CSS-inspired paradigm. It would be like having a CSS property accept HTML elements as a value. + +To support this pattern, LiveView Native represents SwiftUI Views using dot notation within a stylesheet. + +## + +{: .col-2} + +### SwiftUI + +```swift +Image(name: "turtlerock") + .clipShape(Circle()) +``` + +### LiveView Native + +Stylesheet + +```elixir +defmodule MyAppWeb.Styles.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "clipShape:circle" do + clipShape(.circle) + end + """ +end +``` + +Template + +```heex + +``` + +## Named Content Areas + +SwiftUI Views can have content area modifiers that accept one or more views inside a closure (the curly `{}` brackets). Views within the named content area can even have their own modifiers. + +LiveView Native supports named content areas through the `template` attribute. The stylesheet specifies a name for the content area using an atom. The view's `template` attribute should match the atom used. + +## + +{: .col-2} + +### SwiftUI + +Unnamed version + +```swift +Image("turtlerock") + .overlay { + Circle().stroke(.white, lineWidth: 4) + } +``` + +Named version (equivalent to the above) + +```swift +Image("turtlerock") + .overlay { + content: Circle().stroke(.white, lineWidth: 4) + } +``` + +### LiveView Native + +Stylesheet + +```elixir +defmodule MyAppWeb.Styles.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + + ~SHEET""" + "overlay-circle" do + overlay(content: :circle) + end + "white-border" do + stroke(.white, lineWidth: 4) + end + """ +end +``` + +Template + +```heex + + + +``` + +## Conclusion + +Use this cheatsheet for reference whenever you're converting SwiftUI examples into LiveView Native code and you should have the tools you need to build Native UIs from SwiftUI examples. We strongly encourage you to bookmark this page as it will likely be helpful in the future. \ No newline at end of file diff --git a/lib/mix/tasks/lvn.swiftui.gen.docs.ex b/lib/mix/tasks/lvn.swiftui.gen.docs.ex index eef1f1c5d..3b1b3e2f2 100644 --- a/lib/mix/tasks/lvn.swiftui.gen.docs.ex +++ b/lib/mix/tasks/lvn.swiftui.gen.docs.ex @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen.Docs do # Using a temporary folder outside of the project avoids ElixirLS file watching issues defp temp_doc_folder, do: Path.join(System.tmp_dir!(), "temp_swiftui_docs") defp generate_swift_lvn_docs_command, do: ~c"xcodebuild docbuild -scheme LiveViewNative -destination generic/platform=iOS -derivedDataPath #{temp_doc_folder()} -skipMacroValidation -skipPackagePluginValidation" - @swiftui_interface_path "Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface" + @swiftui_interface_path "Platforms/XROS.platform/Developer/SDKs/XROS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-xros.swiftinterface" defp generate_modifier_documentation_extensions(xcode_path), do: ~c(xcrun swift run ModifierGenerator documentation-extensions --interface "#{Path.join(xcode_path, @swiftui_interface_path)}" --output Sources/LiveViewNative/LiveViewNative.docc/DocumentationExtensions) @generate_documentation_extensions ~c(xcrun swift package plugin --allow-writing-to-package-directory generate-documentation-extensions) defp modifier_list(xcode_path), do: ~s(xcrun swift run ModifierGenerator list --interface "#{Path.join(xcode_path, @swiftui_interface_path)}" --modifier-search-path Sources/LiveViewNative/Stylesheets/Modifiers) diff --git a/lib/mix/tasks/lvn.swiftui.gen.ex b/lib/mix/tasks/lvn.swiftui.gen.ex index c9fe98710..b6a64da06 100644 --- a/lib/mix/tasks/lvn.swiftui.gen.ex +++ b/lib/mix/tasks/lvn.swiftui.gen.ex @@ -3,6 +3,8 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen do alias Mix.LiveViewNative.Context + @macos? :os.type() == {:unix, :darwin} + @shortdoc "Generates the SwiftUI Project for LiveView Native" @moduledoc """ #{@shortdoc} @@ -32,10 +34,7 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen do copy_new_files(context, files) if Keyword.get(context.opts, :xcodegen, true) do - context - |> install_xcodegen() - |> run_xcodegen() - |> remove_xcodegen_files() + run_xcodegen(context, @macos?) end :ok @@ -62,30 +61,6 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen do """) end - defp install_xcodegen(context) do - unless System.find_executable("xcodegen") do - cond do - # Install with Mint - System.find_executable("mint") -> - status_message("running", "mint install yonaskolb/xcodegen") - System.cmd("mint", ["install", "yonaskolb/xcodegen"]) - - # Install with Homebrew - System.find_executable("brew") -> - status_message("running", "brew install xcodegen") - System.cmd("brew", ["install", "xcodegen"]) - - # Clone from GitHub (fallback) - true -> - File.mkdir_p("_build/tmp/xcodegen") - status_message("running", "git clone https://github.com/yonaskolb/XcodeGen.git") - System.cmd("git", ["clone", "https://github.com/yonaskolb/XcodeGen.git", "_build/tmp/xcodegen"]) - end - end - - context - end - def files_to_be_generated(context) do root = Application.app_dir(:live_view_native_swiftui) @@ -94,7 +69,7 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen do web_prefix = Mix.Phoenix.web_path(context.context_app) copy_files? = Keyword.get(context.opts, :copy, true) - xcodegen? = Keyword.get(context.opts, :xcodegen, true) + xcodegen? = Keyword.get(context.opts, :xcodegen, true) && @macos? components_path = Path.join(web_prefix, "components") @@ -155,23 +130,26 @@ defmodule Mix.Tasks.Lvn.Swiftui.Gen do context end - defp run_xcodegen(%{base_module: base_module, native_path: native_path} = context) do + defp run_xcodegen(%{base_module: base_module, native_path: native_path} = context, true) do xcodegen_env = [ {"LVN_APP_NAME", inspect(base_module)}, {"LVN_BUNDLE_IDENTIFIER", "com.example.#{inspect(base_module)}"} ] - if File.exists?("_build/tmp/xcodegen") do - xcodegen_spec_path = Path.join([native_path, "project.yml"]) + spec_path = Path.join([native_path, "project.yml"]) + bin_path = + :code.priv_dir(:live_view_native_swiftui) + |> IO.iodata_to_binary() + |> Path.join("bin/xcodegen") - System.cmd("swift", ["run", "xcodegen", "generate", "-s", xcodegen_spec_path], cd: "_build/tmp/xcodegen", env: xcodegen_env) - else - System.cmd("xcodegen", ["generate"], cd: native_path, env: xcodegen_env) - end + System.cmd(bin_path, ["generate", "-s", spec_path], env: xcodegen_env) - context + remove_xcodegen_files(context) end + defp run_xcodegen(_context, false), + do: Mix.shell().info("You must run this task from MacOS to use xcodegen") + defp remove_xcodegen_files(%{native_path: native_path} = context) do ["base_spec.yml", "project_watchos.yml", "project.yml"] |> Enum.map(&(Path.join([native_path, &1]))) diff --git a/lib/mix/tasks/lvn.swiftui.gen.livemarkdown.ex b/lib/mix/tasks/lvn.swiftui.gen.livemarkdown.ex new file mode 100644 index 000000000..781a3986e --- /dev/null +++ b/lib/mix/tasks/lvn.swiftui.gen.livemarkdown.ex @@ -0,0 +1,50 @@ +defmodule Mix.Tasks.Lvn.Swiftui.Gen.Livemarkdown do + @moduledoc "Generates ex_doc friendly markdown guides from Livebook notebooks" + @source "livebooks" + @destination "livebooks/markdown" + use Mix.Task + require Logger + def run(_args) do + Logger.info("RUNNING LIVEBOOK DOCS") + # clean up old notebooks + File.rm_rf(@destination) + File.mkdir(@destination) + + File.ls!(@source) |> Enum.filter(fn file_name -> file_name =~ ".livemd" end) + |> Enum.each(fn file_name -> + ex_doc_friendly_content = make_ex_doc_friendly(File.read!("#{@source}/#{file_name}"), file_name) + File.write!("#{@destination}/#{Path.basename(file_name, ".livemd")}.md", ex_doc_friendly_content) + end) + end + + def make_ex_doc_friendly(content, file_name) do + content + |> replace_setup_section_with_badge(file_name) + |> remove_kino_boilerplate() + |> convert_details_sections() + end + + defp replace_setup_section_with_badge(content, file_name) do + badge = "[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%#{file_name})" + String.replace(content, ~r/```elixir(.|\n)+?```/, badge, global: false) + end + + defp remove_kino_boilerplate(content) do + content + |> String.replace(""" + require Server.Livebook + import Server.Livebook + import Kernel, except: [defmodule: 2] + + """, "") + |> String.replace(~r/\|\> Server\.SmartCells\.LiveViewNative\.register\(\".+\"\)\n\nimport Server\.Livebook, only: \[\]\nimport Kernel\n:ok\n/, "") + |> String.replace(~r/\|\> Server\.SmartCells\.RenderComponent\.register\(\)\n\nimport Server\.Livebook, only: \[\]\nimport Kernel\n:ok\n/, "") + end + + defp convert_details_sections(content) do + # Details sections do not properly render on ex_doc, so we convert them to headers + Regex.replace(~r/([^<]+)<\/summary>((.|\n)+?)(?=<\/details>)<\/details>/, content, fn _full, title, content -> + "### #{title}#{content}" + end) + end +end diff --git a/livebooks/create-a-swiftui-application.livemd b/livebooks/create-a-swiftui-application.livemd new file mode 100644 index 000000000..b95e1d1d4 --- /dev/null +++ b/livebooks/create-a-swiftui-application.livemd @@ -0,0 +1,317 @@ + + +# Create a SwiftUI Application + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +This guide will teach you how to set up a SwiftUI Application for LiveView Native. + +Typically, we recommend using the `mix lvn.install` task as described in the [Installation Guide](https://hexdocs.pm/live_view_native/installation.html#5-enable-liveview-native) to add LiveView Native to a Phoenix project. However, we will walk through the steps of manually setting up an Xcode iOS project to learn how the iOS side of a LiveView Native application works. + +In future lessons, you'll use this iOS application to view iOS examples in the Xcode simulator (or a physical device if you prefer.) + +## Prerequisites + +First, make sure you have followed the [Getting Started](https://hexdocs.pm/live_view_native/getting_started.md) guide. Then, evaluate the smart cell below. Visit http://localhost:4000 to ensure the Phoenix server runs properly. You should see the text `Hello from LiveView!` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Create the iOS Application + +Open Xcode and select Create New Project. + + + +![Xcode Create New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-new-project.png?raw=true) + + + +To create an iOS application, select the **iOS** and **App** options and click **Next**. + + + +![Xcode Create Template For New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-template-for-new-project.png?raw=true) + + + +Choose options for your new project that match the following image, then click **Next**. + +![Xcode Choose Options For Your New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-choose-options-for-your-new-project.png?raw=true) + +
+What do these options mean? + +* **Product Name:** The name of the application. This can be any valid name. We've chosen **Guides**. +* **Organization Identifier:** A reverse DNS string that uniquely identifies your organization. If you don't have a company identifier, [Apple recommends](https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app) using **com.example.your_name** where **your_name** is your organization or personal name. +* **Interface:** The Xcode user interface to use. Select **SwiftUI** to create an app that uses the SwiftUI app lifecycle. +* **Language:** Determines which language Xcode should use for the project. Select **Swift**. + +
+ + + +Select an appropriate folder location to store the iOS project, then click **Create.** + + + +![Xcode select folder location](https://github.com/liveview-native/documentation_assets/blob/main/xcode-select-folder-location.png?raw=true) + + + +You should see the default iOS application generated by Xcode. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/default-xcode-app.png?raw=true) + +## Add the LiveView Client SwiftUI Package + +The [LiveView Client SwiftUI Package](https://github.com/liveview-native/liveview-client-swiftui) allows your SwiftUI client application to connect to a Phoenix LiveView server. + +To install the package, from Xcode from the project you just created, select **File -> Add Package Dependencies**. Then, search for `liveview-client-swiftui`. Once you have chosen the package, click **Add Package**. + +The image below displays `0.2.0`. You should select the latest version of LiveView Native. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/add-liveview-swiftui-client-package-0.2.0.png?raw=true) + + + +Choose the Package Products for `liveview-client-swiftui`. Select **Guides** as the target for `LiveViewNative` and `LiveViewNativeStylesheet` to add these dependencies to your iOS project. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/select-package-products.png?raw=true) + + + +At this point, you'll need to enable permissions for plugins used by LiveView Native. You should see the following prompt. Click **Trust & Enable All**. + + + +![Xcode some build plugins are disabled](https://github.com/liveview-native/documentation_assets/blob/main/xcode-some-build-plugins-are-disabled.png?raw=true) + + + +You'll also need to manually navigate to the error tab (shown below) to trust and enable packages. Click on each error to trigger a prompt. Select **Trust & Enable** to enable the plugin. + +The specific plugins are subject to change. At the time of writing you need to enable `LiveViewNativeStylesheetMacros`, `LiveViewNativeMacros`, and `CasePathMacros` as shown in the images below. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-stylesheet.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-macros.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-case-path-macros.png?raw=true) + +## Add the LiveView Live Form Package + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) provides forms for LiveView Native SwiftUI. + +To install LiveView Native Form, we need to add the [liveview-native-live-form](https://github.com/liveview-native/liveview-native-live-form) SwiftUI package to our iOS application. The steps will be mostly similar to what you have already setup with the `liveview-client-swiftui` package. + +Follow the [LiveView Native Form Installation Guide](https://github.com/liveview-native/liveview-native-live-form?tab=readme-ov-file#liveviewnativeliveform) on that project's README to add the `liveview-native-live-form` package to the SwiftUI application. + +Come back to this guide and continue after you have finished the installation process. + +## Setup the SwiftUI LiveView + +The [ContentView](https://developer.apple.com/tutorials/swiftui-concepts/exploring-the-structure-of-a-swiftui-app#Content-view) contains the main view of our iOS application. + +Replace the code in the `ContentView` file with the following to connect the SwiftUI application and the Phoenix application. + + + +```swift +import SwiftUI +import LiveViewNative + +struct ContentView: View { + + var body: some View { + #LiveView(.automatic( + development: .localhost, + production: URL(string: "https://example.com/")! + )) + } +} + + +// Optionally preview the native UI in Xcode +#Preview { + ContentView() +} +``` + + + +
+(Optional) configuration for using a physical device + +You may wish to use a physical device instead of a simulator. If so, you'll need to change the `development` configuration to use your machine's IP address instead of localhost as seen in the example below. + +```elixir +#LiveView( + .automatic( + development: URL(string: "http://192.168.1.xxx:4000")!, + production: URL(string: "https://example.com")! + ) +) +``` + +Make sure to replace `192.168.1.xxx` with your IP address. You can run the following command in the IEx shell to find your machine's IP address: + +```elixir +iex> IO.puts :os.cmd(~c[ipconfig getifaddr en0]) +``` + +
+ + + +The code above sets up the SwiftUI LiveView. By default, the SwiftUI LiveView connects to any Phoenix app running on http://localhost:4000. + + + + + +```mermaid +graph LR; + subgraph I[iOS App] + direction TB + ContentView + SL[SwiftUI LiveView] + end + subgraph P[Phoenix App] + LiveView + end + SL --> P + ContentView --> SL + + +``` + +## Start the Active Scheme + +Click the run button to build the project and run it on the iOS simulator. Alternatively you may go to `Product` in the top menu then press `Run`. + +> A [build scheme](https://developer.apple.com/documentation/xcode/build-system) contains a list of targets to build and any configuration and environment details that affect the selected action. When you build and run an app, the scheme tells Xcode what launch arguments to pass to the app. +> +> * https://developer.apple.com/documentation/xcode/build-system + +After you start the active scheme, the simulator should open the iOS application and display `Hello from LiveView Native!` If you encounter any issues, see the Troubleshooting section below. + + + +
+ +
+ +## Troubleshooting + +If you encountered any issues with the native application, here are some troubleshooting steps you can use: + +* **Reset Package Caches:** In the Xcode application go to **File -> Packages -> Reset Package Caches**. +* **Update Packages:** In the Xcode application go to **File -> Packages -> Update to Latest Package Versions**. +* **Rebuild the Active Scheme**: In the Xcode application, press the `start active scheme` button to rebuild the active scheme and run it on the Xcode simulator. +* Update your [Xcode](https://developer.apple.com/xcode/) version if it is not already the latest version +* Check for error messages in the Livebook smart cells. + +You can also [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) if you would like support from the LiveView Native team. diff --git a/livebooks/forms-and-validation.livemd b/livebooks/forms-and-validation.livemd new file mode 100644 index 000000000..1e1f719aa --- /dev/null +++ b/livebooks/forms-and-validation.livemd @@ -0,0 +1,909 @@ +# Forms and Validation + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"}, + {:ecto, "~> 3.11"}, + {:phoenix_ecto, "~> 4.5"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ], + # Ensures that app.js compiles to avoid the switch to longpolling + # when a LiveView doesn't exist yet + esbuild: [ + version: "0.17.11", + server_web: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + ] + ], + force: true +) +``` + +## Overview + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) project makes it easier to build forms in LiveView Native. This project enables you to group different [Control Views](https://developer.apple.com/documentation/swiftui/controls-and-indicators) inside of a `LiveForm` and control them collectively under a single `phx-change` or `phx-submit` event handler, rather than with multiple different `phx-change` event handlers. + +Getting the most out of this material requires some understanding of the [Ecto](https://hexdocs.pm/ecto/Ecto.html) project and in particular a reasonably deep understanding of [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). Review the Ecto documentation if you find any of the examples difficult to follow. + +## Creating a Basic Form + +The LiveView Native `mix lvn.install` task generates a [core_components.swiftui.ex](https://github.com/liveview-native/liveview-client-swiftui/blob/main/priv/templates/lvn.swiftui.gen/core_components.ex) file for native SwiftUI function components similar to the [core_components.ex](https://github.com/phoenixframework/phoenix/blob/main/priv/templates/phx.gen.live/core_components.ex) file generated in a traditional phoenix application for web function components. + +See Phoenix's [Components and HEEx](https://hexdocs.pm/phoenix/components.html) HexDoc documentation if you need a primer on function components. + +In the `core_components.swiftui.ex` file there's a `simple_form/1` component that is a similar abstraction to the `simple_form/1` component found in `core_components.ex`. + +First, we'll see how to use this abstraction at a basic level, then later we'll dive deeper into how forms work under the hood in LiveView Native. + + + +### A Basic Form + +The code below demonstrates a basic form that uses the same event handlers for the `phx-change` and `phx-submit` events on both the web and native versions of the form. + +We'll break down and understand the individual parts of this form in a moment. + +For now, evaluate the following example. Open the native form in your simulator, and open the web form on http://localhost:4000/. Enter some text into both forms, then submit them. Watch the logs in the cell below to see the printed params. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:value]} type="TextField" placeholder="Enter a value" /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns) do + ~H""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:value]} placeholder="Enter a value" /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +After submitting both forms, notice that both the web and native params are the same shape:`%{"my_form" => %{"value" => "some text"}}`. This makes it easier to share event handlers for both web and native. + +Sharing event handlers hugely simplifies and speeds up the process of writing web and native application logic because you only have to write the logic once. Alternatively, if your web and native UI deviates significantly, you can also separate the event handlers. + +## Breaking down a Basic Form + +### Simple Form + +The interface for the native `simple_form/1` and web `simple_form/1` is intentionally identical. + +```heex +<.simple_form for={@form} id="form" phx-submit="submit"> + + +``` + +We'll go into the internal implementation details later on, but for now you can treat these components as functionally identical. Both require a unique `id` and accept the `for` attribute that contains the [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) datastructure containing form fields, error messages, and other form data. + +If you need a refresher on forms in Phoenix, see the [Form Bindings](https://hexdocs.pm/phoenix_live_view/form-bindings.html) HexDoc documentation. + + + +### Inputs + +Both web and native core components define a `input/1` function component. Inputs in the web form and native form differ since one is an abstraction on top of HTML elements and the other is an abstraction on top of SwiftUI Views. Therefore, they have different values for the `type` attribte that determines which input type to render. + +On web, the `input/1` component accepts the following values for the `type` attribute. These reflect [html input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types). + + + +```elixir + attr :type, :string, + default: "text", + values: ~w(checkbox color date datetime-local email file hidden month number password + range radio search select tel text textarea time url week) +``` + +On native, the `input/1` component accepts the following values for the `type` attribute. These reflect the SwiftUI Views from the [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) and [Text Input and Outputs](https://developer.apple.com/documentation/swiftui/text-input-and-output) sections. + + + +```elixir +attr :type, :string, + default: "TextField", + values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden) +``` + +## Changesets + +The [Phoenix.Component.to_form/2](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#to_form/2) function also supports Ecto changesets for form data and error validation. See [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html) for a refresher on changesets. Also see [Form Bindings](https://hexdocs.pm/phoenix_live_view/form-bindings.html) and [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) for a refresher on Phoenix Forms. + +We'll use the following changeset to demonstrate how to validate data in a LiveView Native Live Form. + +```elixir +defmodule User do + import Ecto.Changeset + defstruct [:email] + @types %{email: :string} + + def changeset(user, params) do + {user, @types} + |> cast(params, [:email]) + |> validate_required([:email]) + |> validate_format(:email, ~r/@/) + end +end +``` + +The [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) struct stores the changeset. The `simple_form/1` and `input/1` components for both web and native use the [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) struct and nested [Phoenix.HTML.FormField](https://hexdocs.pm/phoenix_html/Phoenix.HTML.FormField.html) structs to render form data and display errors. + +For example, `:action` field in the changeset determines if errors should display or not. Here's an example we'll use in a moment of faking a database `:insert` action and storing the changeset information inside of a form. + +```elixir +User.changeset(%User{}, %{email: "test"}) +|> Map.put(:action, :insert) +|> Phoenix.Component.to_form() +``` + +Here's an example of how we can use Ecto changesets with the LiveView Native Live Form. Now when we submit or validate the form data we apply the changes to the changeset and store the new version of the form in the socket. The `simple_form/1` and `input/1` components use the form data to render content and display errors. + +Evaluate the cell below and open your iOS application. Submit the form with an invalid email. You should notice a `has invalid format` error appear. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:email]} type="TextField" placeholder="Email" /> + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + changeset = User.changeset(%User{}, %{}) + {:ok, assign(socket, form: to_form(changeset))} + end + + @impl true + def render(assigns) do + ~H""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:email]} placeholder="Email" /> + <:actions> + <.button type="submit"> + Submit + + + + """ + end + + @impl true + def handle_event("submit", %{"user" => params}, socket) do + changeset = + User.changeset(%User{}, params) + # Faking a Database insert action + |> Map.put(:action, :insert) + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, form: to_form(changeset))} + end + + @impl true + def handle_event("validate", %{"user" => params}, socket) do + changeset = + User.changeset(%User{}, params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, form: to_form(changeset))} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Keyboard Types + +The [keyboardType](https://developer.apple.com/documentation/swiftui/view/keyboardtype(_:)) modifier changes the type of keyboard for a TextField view. + +Evaluate the example below to see the different keyboards as you focus on each input. If you don't see the keyboard, go to `I/O` -> `Keyboard` -> `Toggle Software Keyboard` to enable the software keyboard in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form"> + <.input field={@form[:number_pad]} type="TextField" style="keyboardType(.numberPad)"/> + <.input field={@form[:email_address]} type="TextField" style="keyboardType(.emailAddress)"/> + <.input field={@form[:phonePad]} type="TextField" style="keyboardType(.phonePad)"/> + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +For a complete list of accepted keyboard types, see the [UIKeyboardType](https://developer.apple.com/documentation/uikit/uikeyboardtype) documentation. + +## Core Components + +Setting up a LiveView Native application using the generators creates a `core_components.swiftui.ex` file. If you have the [liveview-native-live-form](https://github.com/liveview-native/liveview-native-live-form) dependency, this file includes function components for building forms. + +To better understand how to work with each core component, refer to the `core_components.swiftui.ex` file generated in a Phoenix LiveView Native project. For the core components used in this Livebook, refer to the [core_components.swiftui.ex](https://github.com/liveview-native/kino_live_view_native/blob/main/apps/server_web/lib/server_web/components/core_components.swiftui.ex) from the Kino LiveView Native project. + +We've already been using the two main functions, `simple_form/1` and `input/1`. These are abstractions on top of the native SwiftUI views and some custom views defined by the LiveView Native Live Form library. + +in this section, we'll dive deeper into these abstractions so that you can build your own custom forms. + + + +### Simple Form + +Here's the `simple_form/1` definition. + + + +```elixir + attr :for, :any, required: true, doc: "the datastructure for the form" + attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" + + attr :rest, :global, + include: ~w(autocomplete name rel action enctype method novalidate target multipart), + doc: "the arbitrary HTML attributes to apply to the form tag" + + slot :inner_block, required: true + slot :actions, doc: "the slot for form actions, such as a submit button" + + def simple_form(assigns) do + ~LVN""" + <.form :let={f} for={@for} as={@as} {@rest}> +
+ <%= render_slot(@inner_block, f) %> +
+ <%= for action <- @actions do %> + <%= render_slot(action, f) %> + <% end %> +
+
+ + """ + end +``` + +We show this to highlight the similarity between this form, and the one used in `core_components.ex`. + + + +```elixir +attr :for, :any, required: true, doc: "the datastructure for the form" +attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" + +attr :rest, :global, + include: ~w(autocomplete name rel action enctype method novalidate target multipart), + doc: "the arbitrary HTML attributes to apply to the form tag" + +slot :inner_block, required: true +slot :actions, doc: "the slot for form actions, such as a submit button" + +def simple_form(assigns) do + ~H""" + <.form :let={f} for={@for} as={@as} {@rest}> +
+ <%= render_slot(@inner_block, f) %> +
+ <%= render_slot(action, f) %> +
+
+ + """ +end +``` + + + +### Input + +The `type` attribute on the `input/1` component determines which View to render. Here's the same `input/1` definition. + + + +```elixir +attr :id, :any, default: nil +attr :name, :any +attr :label, :string, default: nil +attr :value, :any + +attr :type, :string, + default: "TextField", + values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden) + +attr :field, Phoenix.HTML.FormField, + doc: "a form field struct retrieved from the form, for example: @form[:email]" + +attr :errors, :list, default: [] +attr :checked, :boolean, doc: "the checked flag for checkbox inputs" +attr :prompt, :string, default: nil, doc: "the prompt for select inputs" +attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" +attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + +attr :min, :any, default: nil +attr :max, :any, default: nil + +attr :placeholder, :string, default: nil + +attr :readonly, :boolean, default: false + +attr :autocomplete, :string, + default: "on", + values: ~w(on off) + +attr :rest, :global, + include: ~w(disabled step) + +slot :inner_block + +def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + # Input Definition +end +``` + +The `input/1` function then continues to call a separate function definition depending on the `type` attribute. For example, here's the `"TextField"` definition: + + + +```elixir +def input(%{type: "TextField"} = assigns) do + ~LVN""" + + <%= @placeholder || @label %> + <.error :for={msg <- @errors}><%= msg %> + + """ +end +``` + +Here's a list of valid options with links to their documentation: + +* [TextFieldLink](https://developer.apple.com/documentation/swiftui/textfieldlink) +* [DatePicker](https://developer.apple.com/documentation/swiftui/datepicker) +* [MultiDatePicker](https://developer.apple.com/documentation/swiftui/multidatepicker) +* [Picker](https://developer.apple.com/documentation/swiftui/picker) +* [SecureField](https://developer.apple.com/documentation/swiftui/securefield) +* [Slider](https://developer.apple.com/documentation/swiftui/slider) +* [Stepper](https://developer.apple.com/documentation/swiftui/stepper) +* [TextEditor](https://developer.apple.com/documentation/swiftui/texteditor) +* [TextField](https://developer.apple.com/documentation/swiftui/textfield) +* [Toggle](https://developer.apple.com/documentation/swiftui/toggle) +* hidden + +For more on the form compatible views see the [Interactive SwiftUI Views](https://hexdocs.pm/liveview-client-swiftui/interactive-swiftui-views.html) guide. + + + +### Core Components vs Views + +SwiftUI Core Components attempts to make the API consistent and easy to remember between platforms. For that reason, we deviate somewhat from the interface used by SwiftUI. + +Let's take the Slider view as an example. The Slider view accepts the `min` and `max` attributes instead of `lowerBound` and `upperBound` because they better reflect the html [range](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range) slider. The component also accepts the `label` attribute instead of using children for the same reason. + + + +```elixir + def input(%{type: "Slider"} = assigns) do + ~LVN""" + + + <%= @label %> + <%= @label %> + + <.error :for={msg <- @errors}><%= msg %> + + """ + end +``` + + + +### Labels with Form Data + +Sometimes you may wish to use data within the form separately as part of your UI. For example, let's say we want to have a Stepper view with a dynamic label based on the current step value. In these cases, you can access form data through the `@form.params`. + +Here's an example showing how to have a dynamic label based on the Stepper view's current value. Evaluate the example below and run it in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input + field={@form[:value]} + type="Stepper" + label={"Value: #{@form.params["value"]}"} + /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{"value" => 0}, as: "my_form"))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", %{"my_form" => params}, socket) do + IO.inspect(params, label: "PARAMS") + {:noreply, assign(socket, form: to_form(params, as: "my_form"))} + end + + @impl true + def handle_event("validate", %{"my_form" => params}, socket) do + {:noreply, assign(socket, form: to_form(params, as: "my_form"))} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn + +Create a form that has `TextField`, `Slider`, `Toggle`, and `DatePicker` fields. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:text]} type="TextField" placeholder="Enter a value" /> + <.input field={@form[:slider]} type="Slider"/> + <.input field={@form[:toggle]} type="Toggle"/> + <.input field={@form[:date_picker]} type="DatePicker"/> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns), do: "" + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +``` + +
+ + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Native Views + +The LiveView Native LiveForm library defines [a few custom SwiftUI views](https://github.com/liveview-native/liveview-native-live-form/tree/main/swiftui/Sources/LiveViewNativeLiveForm) such as `LiveForm` and `LiveSubmitButton`. Several core components use these components. + +Typically, you won't need to use these views directly and will instead rely upon the core components directly. + +## Mini Project: User Form + +Taking everything you've learned, you're going to create a more complex user form with data validation and error displaying. + + + +### User Changeset + +First, create a `CustomUser` changeset below that handles data validation. + +**Requirements** + +* A user should have a `name` field +* A user should have a `password` string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn't have a database. In real-world apps passwords should **never** be stored as a simple string, they should be encrypted. +* A user should have an `age` number field greater than `0` and less than `200`. +* A user should have an `email` field which matches an email format (including `@` is sufficient). +* A user should have a `accepted_terms` field which must be true. +* A user should have a `birthdate` field which is a date. +* All fields should be required + +
+Example Solution + +```elixir +defmodule CustomUser do + import Ecto.Changeset + defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate] + + @types %{ + name: :string, + password: :string, + age: :integer, + email: :string, + accepted_terms: :boolean, + birthdate: :date + } + + def changeset(user, params) do + {user, @types} + |> cast(params, Map.keys(@types)) + |> validate_required(Map.keys(@types)) + |> validate_format(:email, ~r/@/) + |> validate_length(:password, min: 10) + |> validate_number(:age, greater_than: 0, less_than: 200) + |> validate_acceptance(:accepted_terms) + end +end +``` + +
+ +```elixir +defmodule CustomUser do + # define the struct keys + defstruct [] + + # define the types + @types %{} + + def changeset(user, params) do + # Enter your solution + end +end +``` + +### LiveView + +Next, create a Live View that lets the user enter their information and displays errors for invalid information. + +**Requirements** + +* The `name` field should be a `TextField`. +* The `email` field should be a `TextField`. +* The `password` field should be a `SecureField`. +* The `age` field should be a `TextField` with a `.numberPad` keyboard or a `Slider`. +* The `accepted_terms` field should be a `Toggle`. +* The `birthdate` field should be a `DatePicker`. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:name]} type="TextField" placeholder="name" /> + <.input field={@form[:email]} type="TextField" placeholder="email" /> + <.input field={@form[:password]} type="SecureField" placeholder="password" /> + <.input field={@form[:age]} type="TextField" placeholder="age" style="keyboardType(.numberPad)" /> + <.input field={@form[:accepted_terms]} type="Toggle"/> + <.input field={@form[:birthdate]} type="DatePicker"/> + + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + changeset = User.changeset(%CustomUser{}, %{}) + {:ok, assign(socket, form: to_form(changeset, as: :user))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", %{"user" => params}, socket) do + changeset = + CustomUser.changeset(%CustomUser{}, params) + # Faking a Database insert action + |> Map.put(:action, :insert) + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, form: to_form(changeset, as: :user))} + end + + @impl true + def handle_event("validate", %{"user" => params}, socket) do + IO.inspect(params) + changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, :validate) + |> IO.inspect() + + {:noreply, assign(socket, form: to_form(changeset, as: :user))} + end +end +``` + +
+ + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + # Remember to assign the form + {:ok, socket} + end + + @impl true + def render(assigns), do: ~H"" + + # Event handlers for form validation and submission go here +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/livebooks/getting-started.livemd b/livebooks/getting-started.livemd new file mode 100644 index 000000000..16e93a01d --- /dev/null +++ b/livebooks/getting-started.livemd @@ -0,0 +1,154 @@ +# Getting Started + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +Our livebook guides provide step-by-step lessons to help you learn LiveView Native using Livebook. These guides assume that you already have some familiarity with Phoenix LiveView applications. + +You can read these guides online on HexDocs, but for the best experience, we recommend clicking on the "Run in Livebook" badge to import and run the guide locally with Livebook. + +You may complete guides individually, but we suggest following them chronologically for the most comprehensive learning experience. + +## Prerequisites + +To use these guides, you'll need to install the following prerequisites: + +* [Elixir/Erlang](https://elixir-lang.org/install.html) +* [Livebook](https://livebook.dev/) +* [Xcode](https://developer.apple.com/xcode/) + +While not necessary for our guides, we also recommend you install the following for general LiveView Native development: + +* [Phoenix](https://hexdocs.pm/phoenix/installation.html) +* [PostgreSQL](https://www.postgresql.org/download/) +* [LiveView Native VS Code Extension](https://github.com/liveview-native/liveview-native-vscode) + +## Hello World + +If you are not already running this guide in Livebook, click on the "Run in Livebook" badge at the top of this page to import it. + +Then, you can evaluate the following smart cell and visit http://localhost:4000 to ensure this Livebook works correctly. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +In an upcoming lesson, you'll set up an iOS application with Xcode to run native code examples. + +## Your Turn: Live Reloading + +In the above LiveView, change `Hello from LiveView!` to `Hello again from LiveView!`. After making the change, reevaluate the cell. Notice that the application live reloads and automatically updates in the browser. + +## Kino LiveView Native + +To run a Phoenix + LiveView Native application from within Livebook we built the [Kino LiveView Native](https://github.com/liveview-native/kino_live_view_native) library. + +Whenever you run one of our Livebooks, a server starts on localhost:4000. Ensure no other servers are running on port 4000, or you may experience issues. + +## Troubleshooting + +Some common issues you may encounter are: + +* Another server is already running on port 4000. +* Your version of Livebook needs to be updated. +* Your version of Elixir/Erlang needs to be updated. +* Your version of Xcode needs to be updated. +* This Livebook has cached outdated versions of dependencies + +Ensure you have the latest versions of all necessary software installed, and ensure no other servers are running on port 4000. + +To clear the cache, you can click the `Setup without cache` button revealed by clicking the dropdown next to the `setup` button at the top of the Livebook. + +If that does not resolve the issue, you can [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) to receive support from the LiveView Native team. diff --git a/livebooks/interactive-swiftui-views.livemd b/livebooks/interactive-swiftui-views.livemd new file mode 100644 index 000000000..77ac46123 --- /dev/null +++ b/livebooks/interactive-swiftui-views.livemd @@ -0,0 +1,954 @@ +# Interactive SwiftUI Views + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +In this guide, you'll learn how to build interactive LiveView Native applications using event bindings. + +This guide assumes some existing familiarity with [Phoenix Bindings](https://hexdocs.pm/phoenix_live_view/bindings.html) and how to set/access state stored in the LiveView's socket assigns. To get the most out of this material, you should already understand the `assign/3`/`assign/2` function, and how event bindings such as `phx-click` interact with the `handle_event/3` callback function. + +We'll use the following LiveView and define new render component examples throughout the guide. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Event Bindings + +We can bind any available `phx-*` [Phoenix Binding](https://hexdocs.pm/phoenix_live_view/bindings.html) to a SwiftUI Element. However certain events are not available on native. + +LiveView Native currently supports the following events on all SwiftUI views: + +* `phx-window-focus`: Fired when the application window gains focus, indicating user interaction with the Native app. +* `phx-window-blur`: Fired when the application window loses focus, indicating the user's switch to other apps or screens. +* `phx-focus`: Fired when a specific native UI element gains focus, often used for input fields. +* `phx-blur`: Fired when a specific native UI element loses focus, commonly used with input fields. +* `phx-click`: Fired when a user taps on a native UI element, enabling a response to tap events. + +> The above events work on all SwiftUI views. Some events are only available on specific views. For example, `phx-change` is available on controls and `phx-throttle/phx-debounce` is available on views with events. + +There is also a [Pull Request](https://github.com/liveview-native/liveview-client-swiftui/issues/1095) to add Key Events which may have been merged since this guide was published. + +## Basic Click Example + +The `phx-click` event triggers a corresponding [handle_event/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_event/3) callback function whenever a SwiftUI view is pressed. + +In the example below, the client sends a `"ping"` event to the server, and trigger's the LiveView's `"ping"` event handler. + +Evaluate the example below, then click the `"Click me!"` button. Notice `"Pong"` printed in the server logs below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("ping", _params, socket) do + IO.puts("Pong") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Click Events Updating State + +Event handlers in LiveView can update the LiveView's state in the socket. + +Evaluate the cell below to see an example of incrementing a count. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Your Turn: Decrement Counter + +You're going to take the example above, and create a counter that can **both increment and decrement**. + +There should be two buttons, each with a `phx-click` binding. One button should bind the `"decrement"` event, and the other button should bind the `"increment"` event. Each event should have a corresponding handler defined using the `handle_event/3` callback function. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <%= @count %> + + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("decrement", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <%= @count %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Selectable Lists + +`List` views support selecting items within the list based on their id. To select an item, provide the `selection` attribute with the item's id. + +Pressing a child item in the `List` on a native device triggers the `phx-change` event. In the example below we've bound the `phx-change` event to send the `"selection-changed"` event. This event is then handled by the `handle_event/3` callback function and used to change the selected item. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Item <%= i %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, selection: "None")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Expandable Lists + +`List` views support hierarchical content using the [DisclosureGroup](https://developer.apple.com/documentation/swiftui/disclosuregroup) view. Nest `DisclosureGroup` views within a list to create multiple levels of content as seen in the example below. + +To control a `DisclosureGroup` view, use the `isExpanded` boolean attribute as seen in the example below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Level 1 + Item 1 + Item 2 + Item 3 + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :is_expanded, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"isExpanded" => is_expanded}, socket) do + {:noreply, assign(socket, is_expanded: is_expanded)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Multiple Expandable Lists + +The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Level 1 + Item 1 + + Level 2 + Item 2 + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle-" <> level, %{"isExpanded" => is_expanded}, socket) do + level = String.to_integer(level) + + {:noreply, + assign( + socket, + :expanded_groups, + Map.replace!(socket.assigns.expanded_groups, level, is_expanded) + )} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Forms + +In Phoenix, form elements must be inside of a form. Phoenix only captures events if the element is wrapped in a form. However in SwiftUI there is no similar concept of forms. To bridge the gap, we built the [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) library. This library provides several views to enable writing views in a single form. + +Phoenix Applications setup with LiveView native include a `core_components.ex` file. This file contains several components for building forms. Generally, We recommend using core components rather than the views. We're going to cover the views directly so you understand how to build forms from scratch and how we built the core components. However, in the [Forms and Validations](https://hexdocs.pm/live_view_native/forms-and-validation.html) reading we'll cover using core components. + + + +### LiveForm + +The `LiveForm` view must wrap views to capture events from the `phx-change` or `phx-submit` event. The `phx-change` event sends a message to the LiveView anytime the control or indicator changes its value. The `phx-submit` event sends a message to the LiveView when a user clicks the `LiveSubmitButton`. The params of the message are based on the name of the [Binding](https://developer.apple.com/documentation/swiftui/binding) argument of the view's initializer in SwiftUI. + +Here's some example boilerplate for a `LiveForm`. The `id` attribute is required. + +```html + + + Button Text + +``` + + + +### Basic Example using TextField + +The following example shows you how to connect a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) with a `phx-change` event and `phx-submit` binding to a corresponding event handler. + +Evaluate the example below. Type into the text field and press submit on your iOS simulator. Notice the inspected `params` appear in the server logs in the console below as a map of `%{"my-input" => value}` based on the `name` attribute on the `TextField` view. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Enter text here + Submit + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + require Logger + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change", params, socket) do + Logger.info("Change params: #{inspect(params)}") + {:noreply, socket} + end + + @impl true + def handle_event("submit", params, socket) do + Logger.info("Submitted params: #{inspect(params)}") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Event Handlers + +The `phx-change` and `phx-submit` event handlers should generally be bound to the LiveForm. However, you can also bind the event handlers directly to the input view if you want to separately handle a single view's change events. + + + +```elixir + + Enter text here + Submit + +``` + +## Controls and Indicators + +SwiftUI organizes interactive views in the [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) section. You may refer to this documentation when looking for views that belong within a form. + +We'll demonstrate how to work with a few common control and indicator views. + + + +### Slider + +This code example renders a SwiftUI [Slider](https://developer.apple.com/documentation/swiftui/slider). It triggers the change event when the slider is moved and sends a `"slide"` message. The `"slide"` event handler then logs the value to the console. + +The [Slider](https://developer.apple.com/documentation/swiftui/slider) view uses **named content areas** `minumumValueLabel` and `maximumValueLabel`. The example below demonstrates how to represent these areas using the `template` attribute. + +This example also demonstrates how to use the params sent by the slider to store a value in the socket and use it elsewhere in the template. + +Evaluate the example and enter some text in your iOS simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + 0% + 100% + + + <%= @percentage %> + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :percentage, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("slide", %{"my-slider" => value}, socket) do + {:noreply, assign(socket, :percentage, value)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Stepper + +This code example renders a SwiftUI [Stepper](https://developer.apple.com/documentation/swiftui/stepper). It triggers the change event and sends a `"change-tickets"` message when the stepper increments or decrements. The `"change-tickets"` event handler then updates the number of tickets stored in state, which appears in the UI. + +Evaluate the example and increment/decrement the step. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Tickets <%= @tickets %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :tickets, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change-tickets", %{"my-stepper" => tickets}, socket) do + {:noreply, assign(socket, :tickets, tickets)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Toggle + +This code example renders a SwiftUI [Toggle](https://developer.apple.com/documentation/swiftui/toggle). It triggers the change event and sends a `"toggle"` message when toggled. The `"toggle"` event handler then updates the `:on` field in state, which allows the `Toggle` view to be toggled o through the `isOn` attribute. + +Evaluate the example below and click on the toggle. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + On/Off + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :on, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"my-toggle" => on}, socket) do + {:noreply, assign(socket, :on, on)} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### DatePicker + +The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. Evaluate the example below and select a date to see the date params appear in the console below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + require Logger + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :date, nil)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", params, socket) do + Logger.info("Date Params: #{inspect(params)}") + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Parsing Dates + +The date from the `DatePicker` is in iso8601 format. You can use the `from_iso8601` function to parse this string into a `DateTime` struct. + +```elixir +iso8601 = "2024-01-17T20:51:00.000Z" + +DateTime.from_iso8601(iso8601) +``` + +### Your Turn: Displayed Components + +The `DatePicker` view accepts a `displayedComponents` attribute with the value of `"hourAndMinute"` or `"date"` to only display one of the two components. By default, the value is `"all"`. + +You're going to change the `displayedComponents` attribute in the example below to see both of these options. Change `"all"` to `"date"`, then to `"hourAndMinute"`. Re-evaluate the cell between changes and see the updated UI. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", _params, socket) do + {:noreply, socket} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Small Project: Todo List + +Using the previous examples as inspiration, you're going to create a todo list. + +**Requirements** + +* Items should be `Text` views rendered within a `List` view. +* Item ids should be stored in state as a list of integers i.e. `[1, 2, 3, 4]` +* Use a `TextField` to provide the name of the next added todo item. +* An add item `Button` should add items to the list of integers in state when pressed. +* A delete item `Button` should remove the currently selected item from the list of integers in state when pressed. + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Todo... + + + + + <%= content %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type-name", %{"text" => name}, socket) do + {:noreply, assign(socket, :item_name, name)} + end + + def handle_event("add-item", _params, socket) do + updated_items = [ + {"item-#{socket.assigns.next_item_id}", socket.assigns.item_name} + | socket.assigns.items + ] + + {:noreply, + assign(socket, + item_name: "", + items: updated_items, + next_item_id: socket.assigns.next_item_id + 1 + )} + end + + def handle_event("delete-item", _params, socket) do + updated_items = + Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end) + {:noreply, assign(socket, :items, updated_items)} + end + + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + +
+ + + +### Enter Your Solution Below + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + # Define your mount/3 callback + + @impl true + def render(assigns), do: ~H"" + + # Define your render/3 callback + + # Define any handle_event/3 callbacks +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/livebooks/markdown/create-a-swiftui-application.md b/livebooks/markdown/create-a-swiftui-application.md new file mode 100644 index 000000000..ef63093be --- /dev/null +++ b/livebooks/markdown/create-a-swiftui-application.md @@ -0,0 +1,246 @@ + + +# Create a SwiftUI Application + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%create-a-swiftui-application.livemd) + +## Overview + +This guide will teach you how to set up a SwiftUI Application for LiveView Native. + +Typically, we recommend using the `mix lvn.install` task as described in the [Installation Guide](https://hexdocs.pm/live_view_native/installation.html#5-enable-liveview-native) to add LiveView Native to a Phoenix project. However, we will walk through the steps of manually setting up an Xcode iOS project to learn how the iOS side of a LiveView Native application works. + +In future lessons, you'll use this iOS application to view iOS examples in the Xcode simulator (or a physical device if you prefer.) + +## Prerequisites + +First, make sure you have followed the [Getting Started](https://hexdocs.pm/live_view_native/getting_started.md) guide. Then, evaluate the smart cell below. Visit http://localhost:4000 to ensure the Phoenix server runs properly. You should see the text `Hello from LiveView!` + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Create the iOS Application + +Open Xcode and select Create New Project. + + + +![Xcode Create New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-new-project.png?raw=true) + + + +To create an iOS application, select the **iOS** and **App** options and click **Next**. + + + +![Xcode Create Template For New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-create-template-for-new-project.png?raw=true) + + + +Choose options for your new project that match the following image, then click **Next**. + +![Xcode Choose Options For Your New Project](https://github.com/liveview-native/documentation_assets/blob/main/xcode-choose-options-for-your-new-project.png?raw=true) + +### What do these options mean? + +* **Product Name:** The name of the application. This can be any valid name. We've chosen **Guides**. +* **Organization Identifier:** A reverse DNS string that uniquely identifies your organization. If you don't have a company identifier, [Apple recommends](https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app) using **com.example.your_name** where **your_name** is your organization or personal name. +* **Interface:** The Xcode user interface to use. Select **SwiftUI** to create an app that uses the SwiftUI app lifecycle. +* **Language:** Determines which language Xcode should use for the project. Select **Swift**. + + + + + +Select an appropriate folder location to store the iOS project, then click **Create.** + + + +![Xcode select folder location](https://github.com/liveview-native/documentation_assets/blob/main/xcode-select-folder-location.png?raw=true) + + + +You should see the default iOS application generated by Xcode. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/default-xcode-app.png?raw=true) + +## Add the LiveView Client SwiftUI Package + +The [LiveView Client SwiftUI Package](https://github.com/liveview-native/liveview-client-swiftui) allows your SwiftUI client application to connect to a Phoenix LiveView server. + +To install the package, from Xcode from the project you just created, select **File -> Add Package Dependencies**. Then, search for `liveview-client-swiftui`. Once you have chosen the package, click **Add Package**. + +The image below displays `0.2.0`. You should select the latest version of LiveView Native. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/add-liveview-swiftui-client-package-0.2.0.png?raw=true) + + + +Choose the Package Products for `liveview-client-swiftui`. Select **Guides** as the target for `LiveViewNative` and `LiveViewNativeStylesheet` to add these dependencies to your iOS project. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/select-package-products.png?raw=true) + + + +At this point, you'll need to enable permissions for plugins used by LiveView Native. You should see the following prompt. Click **Trust & Enable All**. + + + +![Xcode some build plugins are disabled](https://github.com/liveview-native/documentation_assets/blob/main/xcode-some-build-plugins-are-disabled.png?raw=true) + + + +You'll also need to manually navigate to the error tab (shown below) to trust and enable packages. Click on each error to trigger a prompt. Select **Trust & Enable** to enable the plugin. + +The specific plugins are subject to change. At the time of writing you need to enable `LiveViewNativeStylesheetMacros`, `LiveViewNativeMacros`, and `CasePathMacros` as shown in the images below. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-stylesheet.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-liveview-native-macros.png?raw=true) + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/trust-and-enable-case-path-macros.png?raw=true) + +## Add the LiveView Live Form Package + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) provides forms for LiveView Native SwiftUI. + +To install LiveView Native Form, we need to add the [liveview-native-live-form](https://github.com/liveview-native/liveview-native-live-form) SwiftUI package to our iOS application. The steps will be mostly similar to what you have already setup with the `liveview-client-swiftui` package. + +Follow the [LiveView Native Form Installation Guide](https://github.com/liveview-native/liveview-native-live-form?tab=readme-ov-file#liveviewnativeliveform) on that project's README to add the `liveview-native-live-form` package to the SwiftUI application. + +Come back to this guide and continue after you have finished the installation process. + +## Setup the SwiftUI LiveView + +The [ContentView](https://developer.apple.com/tutorials/swiftui-concepts/exploring-the-structure-of-a-swiftui-app#Content-view) contains the main view of our iOS application. + +Replace the code in the `ContentView` file with the following to connect the SwiftUI application and the Phoenix application. + + + +```swift +import SwiftUI +import LiveViewNative + +struct ContentView: View { + + var body: some View { + #LiveView(.automatic( + development: .localhost, + production: URL(string: "https://example.com/")! + )) + } +} + + +// Optionally preview the native UI in Xcode +#Preview { + ContentView() +} +``` + + + +### (Optional) configuration for using a physical device + +You may wish to use a physical device instead of a simulator. If so, you'll need to change the `development` configuration to use your machine's IP address instead of localhost as seen in the example below. + +```elixir +#LiveView( + .automatic( + development: URL(string: "http://192.168.1.xxx:4000")!, + production: URL(string: "https://example.com")! + ) +) +``` + +Make sure to replace `192.168.1.xxx` with your IP address. You can run the following command in the IEx shell to find your machine's IP address: + +```elixir +iex> IO.puts :os.cmd(~c[ipconfig getifaddr en0]) +``` + + + + + +The code above sets up the SwiftUI LiveView. By default, the SwiftUI LiveView connects to any Phoenix app running on http://localhost:4000. + + + + + +```mermaid +graph LR; + subgraph I[iOS App] + direction TB + ContentView + SL[SwiftUI LiveView] + end + subgraph P[Phoenix App] + LiveView + end + SL --> P + ContentView --> SL + + +``` + +## Start the Active Scheme + +Click the run button to build the project and run it on the iOS simulator. Alternatively you may go to `Product` in the top menu then press `Run`. + +> A [build scheme](https://developer.apple.com/documentation/xcode/build-system) contains a list of targets to build and any configuration and environment details that affect the selected action. When you build and run an app, the scheme tells Xcode what launch arguments to pass to the app. +> +> * https://developer.apple.com/documentation/xcode/build-system + +After you start the active scheme, the simulator should open the iOS application and display `Hello from LiveView Native!` If you encounter any issues, see the Troubleshooting section below. + + + +
+ +
+ +## Troubleshooting + +If you encountered any issues with the native application, here are some troubleshooting steps you can use: + +* **Reset Package Caches:** In the Xcode application go to **File -> Packages -> Reset Package Caches**. +* **Update Packages:** In the Xcode application go to **File -> Packages -> Update to Latest Package Versions**. +* **Rebuild the Active Scheme**: In the Xcode application, press the `start active scheme` button to rebuild the active scheme and run it on the Xcode simulator. +* Update your [Xcode](https://developer.apple.com/xcode/) version if it is not already the latest version +* Check for error messages in the Livebook smart cells. + +You can also [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) if you would like support from the LiveView Native team. diff --git a/livebooks/markdown/forms-and-validation.md b/livebooks/markdown/forms-and-validation.md new file mode 100644 index 000000000..856d8ad1b --- /dev/null +++ b/livebooks/markdown/forms-and-validation.md @@ -0,0 +1,779 @@ +# Forms and Validation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%forms-and-validation.livemd) + +## Overview + +The [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) project makes it easier to build forms in LiveView Native. This project enables you to group different [Control Views](https://developer.apple.com/documentation/swiftui/controls-and-indicators) inside of a `LiveForm` and control them collectively under a single `phx-change` or `phx-submit` event handler, rather than with multiple different `phx-change` event handlers. + +Getting the most out of this material requires some understanding of the [Ecto](https://hexdocs.pm/ecto/Ecto.html) project and in particular a reasonably deep understanding of [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). Review the Ecto documentation if you find any of the examples difficult to follow. + +## Creating a Basic Form + +The LiveView Native `mix lvn.install` task generates a [core_components.swiftui.ex](https://github.com/liveview-native/liveview-client-swiftui/blob/main/priv/templates/lvn.swiftui.gen/core_components.ex) file for native SwiftUI function components similar to the [core_components.ex](https://github.com/phoenixframework/phoenix/blob/main/priv/templates/phx.gen.live/core_components.ex) file generated in a traditional phoenix application for web function components. + +See Phoenix's [Components and HEEx](https://hexdocs.pm/phoenix/components.html) HexDoc documentation if you need a primer on function components. + +In the `core_components.swiftui.ex` file there's a `simple_form/1` component that is a similar abstraction to the `simple_form/1` component found in `core_components.ex`. + +First, we'll see how to use this abstraction at a basic level, then later we'll dive deeper into how forms work under the hood in LiveView Native. + + + +### A Basic Form + +The code below demonstrates a basic form that uses the same event handlers for the `phx-change` and `phx-submit` events on both the web and native versions of the form. + +We'll break down and understand the individual parts of this form in a moment. + +For now, evaluate the following example. Open the native form in your simulator, and open the web form on http://localhost:4000/. Enter some text into both forms, then submit them. Watch the logs in the cell below to see the printed params. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:value]} type="TextField" placeholder="Enter a value" /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns) do + ~H""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:value]} placeholder="Enter a value" /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +``` + +After submitting both forms, notice that both the web and native params are the same shape:`%{"my_form" => %{"value" => "some text"}}`. This makes it easier to share event handlers for both web and native. + +Sharing event handlers hugely simplifies and speeds up the process of writing web and native application logic because you only have to write the logic once. Alternatively, if your web and native UI deviates significantly, you can also separate the event handlers. + +## Breaking down a Basic Form + +### Simple Form + +The interface for the native `simple_form/1` and web `simple_form/1` is intentionally identical. + +```heex +<.simple_form for={@form} id="form" phx-submit="submit"> + + +``` + +We'll go into the internal implementation details later on, but for now you can treat these components as functionally identical. Both require a unique `id` and accept the `for` attribute that contains the [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) datastructure containing form fields, error messages, and other form data. + +If you need a refresher on forms in Phoenix, see the [Form Bindings](https://hexdocs.pm/phoenix_live_view/form-bindings.html) HexDoc documentation. + + + +### Inputs + +Both web and native core components define a `input/1` function component. Inputs in the web form and native form differ since one is an abstraction on top of HTML elements and the other is an abstraction on top of SwiftUI Views. Therefore, they have different values for the `type` attribte that determines which input type to render. + +On web, the `input/1` component accepts the following values for the `type` attribute. These reflect [html input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types). + + + +```elixir + attr :type, :string, + default: "text", + values: ~w(checkbox color date datetime-local email file hidden month number password + range radio search select tel text textarea time url week) +``` + +On native, the `input/1` component accepts the following values for the `type` attribute. These reflect the SwiftUI Views from the [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) and [Text Input and Outputs](https://developer.apple.com/documentation/swiftui/text-input-and-output) sections. + + + +```elixir +attr :type, :string, + default: "TextField", + values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden) +``` + +## Changesets + +The [Phoenix.Component.to_form/2](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#to_form/2) function also supports Ecto changesets for form data and error validation. See [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html) for a refresher on changesets. Also see [Form Bindings](https://hexdocs.pm/phoenix_live_view/form-bindings.html) and [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) for a refresher on Phoenix Forms. + +We'll use the following changeset to demonstrate how to validate data in a LiveView Native Live Form. + +```elixir +defmodule User do + import Ecto.Changeset + defstruct [:email] + @types %{email: :string} + + def changeset(user, params) do + {user, @types} + |> cast(params, [:email]) + |> validate_required([:email]) + |> validate_format(:email, ~r/@/) + end +end +``` + +The [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) struct stores the changeset. The `simple_form/1` and `input/1` components for both web and native use the [Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html) struct and nested [Phoenix.HTML.FormField](https://hexdocs.pm/phoenix_html/Phoenix.HTML.FormField.html) structs to render form data and display errors. + +For example, `:action` field in the changeset determines if errors should display or not. Here's an example we'll use in a moment of faking a database `:insert` action and storing the changeset information inside of a form. + +```elixir +User.changeset(%User{}, %{email: "test"}) +|> Map.put(:action, :insert) +|> Phoenix.Component.to_form() +``` + +Here's an example of how we can use Ecto changesets with the LiveView Native Live Form. Now when we submit or validate the form data we apply the changes to the changeset and store the new version of the form in the socket. The `simple_form/1` and `input/1` components use the form data to render content and display errors. + +Evaluate the cell below and open your iOS application. Submit the form with an invalid email. You should notice a `has invalid format` error appear. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:email]} type="TextField" placeholder="Email" /> + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + changeset = User.changeset(%User{}, %{}) + {:ok, assign(socket, form: to_form(changeset))} + end + + @impl true + def render(assigns) do + ~H""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:email]} placeholder="Email" /> + <:actions> + <.button type="submit"> + Submit + + + + """ + end + + @impl true + def handle_event("submit", %{"user" => params}, socket) do + changeset = + User.changeset(%User{}, params) + # Faking a Database insert action + |> Map.put(:action, :insert) + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, form: to_form(changeset))} + end + + @impl true + def handle_event("validate", %{"user" => params}, socket) do + changeset = + User.changeset(%User{}, params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, form: to_form(changeset))} + end +end +``` + +## Keyboard Types + +The [keyboardType](https://developer.apple.com/documentation/swiftui/view/keyboardtype(_:)) modifier changes the type of keyboard for a TextField view. + +Evaluate the example below to see the different keyboards as you focus on each input. If you don't see the keyboard, go to `I/O` -> `Keyboard` -> `Toggle Software Keyboard` to enable the software keyboard in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form"> + <.input field={@form[:number_pad]} type="TextField" style="keyboardType(.numberPad)"/> + <.input field={@form[:email_address]} type="TextField" style="keyboardType(.emailAddress)"/> + <.input field={@form[:phonePad]} type="TextField" style="keyboardType(.phonePad)"/> + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end +``` + +For a complete list of accepted keyboard types, see the [UIKeyboardType](https://developer.apple.com/documentation/uikit/uikeyboardtype) documentation. + +## Core Components + +Setting up a LiveView Native application using the generators creates a `core_components.swiftui.ex` file. If you have the [liveview-native-live-form](https://github.com/liveview-native/liveview-native-live-form) dependency, this file includes function components for building forms. + +To better understand how to work with each core component, refer to the `core_components.swiftui.ex` file generated in a Phoenix LiveView Native project. For the core components used in this Livebook, refer to the [core_components.swiftui.ex](https://github.com/liveview-native/kino_live_view_native/blob/main/apps/server_web/lib/server_web/components/core_components.swiftui.ex) from the Kino LiveView Native project. + +We've already been using the two main functions, `simple_form/1` and `input/1`. These are abstractions on top of the native SwiftUI views and some custom views defined by the LiveView Native Live Form library. + +in this section, we'll dive deeper into these abstractions so that you can build your own custom forms. + + + +### Simple Form + +Here's the `simple_form/1` definition. + + + +```elixir + attr :for, :any, required: true, doc: "the datastructure for the form" + attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" + + attr :rest, :global, + include: ~w(autocomplete name rel action enctype method novalidate target multipart), + doc: "the arbitrary HTML attributes to apply to the form tag" + + slot :inner_block, required: true + slot :actions, doc: "the slot for form actions, such as a submit button" + + def simple_form(assigns) do + ~LVN""" + <.form :let={f} for={@for} as={@as} {@rest}> +
+ <%= render_slot(@inner_block, f) %> +
+ <%= for action <- @actions do %> + <%= render_slot(action, f) %> + <% end %> +
+
+ + """ + end +``` + +We show this to highlight the similarity between this form, and the one used in `core_components.ex`. + + + +```elixir +attr :for, :any, required: true, doc: "the datastructure for the form" +attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" + +attr :rest, :global, + include: ~w(autocomplete name rel action enctype method novalidate target multipart), + doc: "the arbitrary HTML attributes to apply to the form tag" + +slot :inner_block, required: true +slot :actions, doc: "the slot for form actions, such as a submit button" + +def simple_form(assigns) do + ~H""" + <.form :let={f} for={@for} as={@as} {@rest}> +
+ <%= render_slot(@inner_block, f) %> +
+ <%= render_slot(action, f) %> +
+
+ + """ +end +``` + + + +### Input + +The `type` attribute on the `input/1` component determines which View to render. Here's the same `input/1` definition. + + + +```elixir +attr :id, :any, default: nil +attr :name, :any +attr :label, :string, default: nil +attr :value, :any + +attr :type, :string, + default: "TextField", + values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden) + +attr :field, Phoenix.HTML.FormField, + doc: "a form field struct retrieved from the form, for example: @form[:email]" + +attr :errors, :list, default: [] +attr :checked, :boolean, doc: "the checked flag for checkbox inputs" +attr :prompt, :string, default: nil, doc: "the prompt for select inputs" +attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" +attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + +attr :min, :any, default: nil +attr :max, :any, default: nil + +attr :placeholder, :string, default: nil + +attr :readonly, :boolean, default: false + +attr :autocomplete, :string, + default: "on", + values: ~w(on off) + +attr :rest, :global, + include: ~w(disabled step) + +slot :inner_block + +def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + # Input Definition +end +``` + +The `input/1` function then continues to call a separate function definition depending on the `type` attribute. For example, here's the `"TextField"` definition: + + + +```elixir +def input(%{type: "TextField"} = assigns) do + ~LVN""" + + <%= @placeholder || @label %> + <.error :for={msg <- @errors}><%= msg %> + + """ +end +``` + +Here's a list of valid options with links to their documentation: + +* [TextFieldLink](https://developer.apple.com/documentation/swiftui/textfieldlink) +* [DatePicker](https://developer.apple.com/documentation/swiftui/datepicker) +* [MultiDatePicker](https://developer.apple.com/documentation/swiftui/multidatepicker) +* [Picker](https://developer.apple.com/documentation/swiftui/picker) +* [SecureField](https://developer.apple.com/documentation/swiftui/securefield) +* [Slider](https://developer.apple.com/documentation/swiftui/slider) +* [Stepper](https://developer.apple.com/documentation/swiftui/stepper) +* [TextEditor](https://developer.apple.com/documentation/swiftui/texteditor) +* [TextField](https://developer.apple.com/documentation/swiftui/textfield) +* [Toggle](https://developer.apple.com/documentation/swiftui/toggle) +* hidden + +For more on the form compatible views see the [Interactive SwiftUI Views](https://hexdocs.pm/liveview-client-swiftui/interactive-swiftui-views.html) guide. + + + +### Core Components vs Views + +SwiftUI Core Components attempts to make the API consistent and easy to remember between platforms. For that reason, we deviate somewhat from the interface used by SwiftUI. + +Let's take the Slider view as an example. The Slider view accepts the `min` and `max` attributes instead of `lowerBound` and `upperBound` because they better reflect the html [range](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range) slider. The component also accepts the `label` attribute instead of using children for the same reason. + + + +```elixir + def input(%{type: "Slider"} = assigns) do + ~LVN""" + + + <%= @label %> + <%= @label %> + + <.error :for={msg <- @errors}><%= msg %> + + """ + end +``` + + + +### Labels with Form Data + +Sometimes you may wish to use data within the form separately as part of your UI. For example, let's say we want to have a Stepper view with a dynamic label based on the current step value. In these cases, you can access form data through the `@form.params`. + +Here's an example showing how to have a dynamic label based on the Stepper view's current value. Evaluate the example below and run it in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input + field={@form[:value]} + type="Stepper" + label={"Value: #{@form.params["value"]}"} + /> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{"value" => 0}, as: "my_form"))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", %{"my_form" => params}, socket) do + IO.inspect(params, label: "PARAMS") + {:noreply, assign(socket, form: to_form(params, as: "my_form"))} + end + + @impl true + def handle_event("validate", %{"my_form" => params}, socket) do + {:noreply, assign(socket, form: to_form(params, as: "my_form"))} + end +end +``` + +### Your Turn + +Create a form that has `TextField`, `Slider`, `Toggle`, and `DatePicker` fields. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:text]} type="TextField" placeholder="Enter a value" /> + <.input field={@form[:slider]} type="Slider"/> + <.input field={@form[:toggle]} type="Toggle"/> + <.input field={@form[:date_picker]} type="DatePicker"/> + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns), do: "" + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +``` + + + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + + <:actions> + <.button type="submit"> + Ping + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "my_form"))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", params, socket) do + IO.inspect(params, label: "Submitted") + {:noreply, socket} + end + + @impl true + def handle_event("validate", params, socket) do + IO.inspect(params, label: "Validating") + {:noreply, socket} + end +end +``` + +### Native Views + +The LiveView Native LiveForm library defines [a few custom SwiftUI views](https://github.com/liveview-native/liveview-native-live-form/tree/main/swiftui/Sources/LiveViewNativeLiveForm) such as `LiveForm` and `LiveSubmitButton`. Several core components use these components. + +Typically, you won't need to use these views directly and will instead rely upon the core components directly. + +## Mini Project: User Form + +Taking everything you've learned, you're going to create a more complex user form with data validation and error displaying. + + + +### User Changeset + +First, create a `CustomUser` changeset below that handles data validation. + +**Requirements** + +* A user should have a `name` field +* A user should have a `password` string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn't have a database. In real-world apps passwords should **never** be stored as a simple string, they should be encrypted. +* A user should have an `age` number field greater than `0` and less than `200`. +* A user should have an `email` field which matches an email format (including `@` is sufficient). +* A user should have a `accepted_terms` field which must be true. +* A user should have a `birthdate` field which is a date. +* All fields should be required + +### Example Solution + +```elixir +defmodule CustomUser do + import Ecto.Changeset + defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate] + + @types %{ + name: :string, + password: :string, + age: :integer, + email: :string, + accepted_terms: :boolean, + birthdate: :date + } + + def changeset(user, params) do + {user, @types} + |> cast(params, Map.keys(@types)) + |> validate_required(Map.keys(@types)) + |> validate_format(:email, ~r/@/) + |> validate_length(:password, min: 10) + |> validate_number(:age, greater_than: 0, less_than: 200) + |> validate_acceptance(:accepted_terms) + end +end +``` + + + +```elixir +defmodule CustomUser do + # define the struct keys + defstruct [] + + # define the types + @types %{} + + def changeset(user, params) do + # Enter your solution + end +end +``` + +### LiveView + +Next, create a Live View that lets the user enter their information and displays errors for invalid information. + +**Requirements** + +* The `name` field should be a `TextField`. +* The `email` field should be a `TextField`. +* The `password` field should be a `SecureField`. +* The `age` field should be a `TextField` with a `.numberPad` keyboard or a `Slider`. +* The `accepted_terms` field should be a `Toggle`. +* The `birthdate` field should be a `DatePicker`. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate"> + <.input field={@form[:name]} type="TextField" placeholder="name" /> + <.input field={@form[:email]} type="TextField" placeholder="email" /> + <.input field={@form[:password]} type="SecureField" placeholder="password" /> + <.input field={@form[:age]} type="TextField" placeholder="age" style="keyboardType(.numberPad)" /> + <.input field={@form[:accepted_terms]} type="Toggle"/> + <.input field={@form[:birthdate]} type="DatePicker"/> + + <:actions> + <.button type="submit"> + Submit + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + changeset = User.changeset(%CustomUser{}, %{}) + {:ok, assign(socket, form: to_form(changeset, as: :user))} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("submit", %{"user" => params}, socket) do + changeset = + CustomUser.changeset(%CustomUser{}, params) + # Faking a Database insert action + |> Map.put(:action, :insert) + |> IO.inspect(label: "Form Field Values") + + {:noreply, assign(socket, form: to_form(changeset, as: :user))} + end + + @impl true + def handle_event("validate", %{"user" => params}, socket) do + IO.inspect(params) + changeset = + CustomUser.changeset(%CustomUser{}, params) + |> Map.put(:action, :validate) + |> IO.inspect() + + {:noreply, assign(socket, form: to_form(changeset, as: :user))} + end +end +``` + + + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + # Remember to assign the form + {:ok, socket} + end + + @impl true + def render(assigns), do: ~H"" + + # Event handlers for form validation and submission go here +end +``` diff --git a/livebooks/markdown/getting-started.md b/livebooks/markdown/getting-started.md new file mode 100644 index 000000000..9410fed21 --- /dev/null +++ b/livebooks/markdown/getting-started.md @@ -0,0 +1,85 @@ +# Getting Started + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%getting-started.livemd) + +## Overview + +Our livebook guides provide step-by-step lessons to help you learn LiveView Native using Livebook. These guides assume that you already have some familiarity with Phoenix LiveView applications. + +You can read these guides online on HexDocs, but for the best experience, we recommend clicking on the "Run in Livebook" badge to import and run the guide locally with Livebook. + +You may complete guides individually, but we suggest following them chronologically for the most comprehensive learning experience. + +## Prerequisites + +To use these guides, you'll need to install the following prerequisites: + +* [Elixir/Erlang](https://elixir-lang.org/install.html) +* [Livebook](https://livebook.dev/) +* [Xcode](https://developer.apple.com/xcode/) + +While not necessary for our guides, we also recommend you install the following for general LiveView Native development: + +* [Phoenix](https://hexdocs.pm/phoenix/installation.html) +* [PostgreSQL](https://www.postgresql.org/download/) +* [LiveView Native VS Code Extension](https://github.com/liveview-native/liveview-native-vscode) + +## Hello World + +If you are not already running this guide in Livebook, click on the "Run in Livebook" badge at the top of this page to import it. + +Then, you can evaluate the following smart cell and visit http://localhost:4000 to ensure this Livebook works correctly. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +In an upcoming lesson, you'll set up an iOS application with Xcode to run native code examples. + +## Your Turn: Live Reloading + +In the above LiveView, change `Hello from LiveView!` to `Hello again from LiveView!`. After making the change, reevaluate the cell. Notice that the application live reloads and automatically updates in the browser. + +## Kino LiveView Native + +To run a Phoenix + LiveView Native application from within Livebook we built the [Kino LiveView Native](https://github.com/liveview-native/kino_live_view_native) library. + +Whenever you run one of our Livebooks, a server starts on localhost:4000. Ensure no other servers are running on port 4000, or you may experience issues. + +## Troubleshooting + +Some common issues you may encounter are: + +* Another server is already running on port 4000. +* Your version of Livebook needs to be updated. +* Your version of Elixir/Erlang needs to be updated. +* Your version of Xcode needs to be updated. +* This Livebook has cached outdated versions of dependencies + +Ensure you have the latest versions of all necessary software installed, and ensure no other servers are running on port 4000. + +To clear the cache, you can click the `Setup without cache` button revealed by clicking the dropdown next to the `setup` button at the top of the Livebook. + +If that does not resolve the issue, you can [raise an issue](https://github.com/liveview-native/liveview-client-swiftui/issues/new) to receive support from the LiveView Native team. diff --git a/livebooks/markdown/interactive-swiftui-views.md b/livebooks/markdown/interactive-swiftui-views.md new file mode 100644 index 000000000..7ef52fd3a --- /dev/null +++ b/livebooks/markdown/interactive-swiftui-views.md @@ -0,0 +1,766 @@ +# Interactive SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%interactive-swiftui-views.livemd) + +## Overview + +In this guide, you'll learn how to build interactive LiveView Native applications using event bindings. + +This guide assumes some existing familiarity with [Phoenix Bindings](https://hexdocs.pm/phoenix_live_view/bindings.html) and how to set/access state stored in the LiveView's socket assigns. To get the most out of this material, you should already understand the `assign/3`/`assign/2` function, and how event bindings such as `phx-click` interact with the `handle_event/3` callback function. + +We'll use the following LiveView and define new render component examples throughout the guide. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Event Bindings + +We can bind any available `phx-*` [Phoenix Binding](https://hexdocs.pm/phoenix_live_view/bindings.html) to a SwiftUI Element. However certain events are not available on native. + +LiveView Native currently supports the following events on all SwiftUI views: + +* `phx-window-focus`: Fired when the application window gains focus, indicating user interaction with the Native app. +* `phx-window-blur`: Fired when the application window loses focus, indicating the user's switch to other apps or screens. +* `phx-focus`: Fired when a specific native UI element gains focus, often used for input fields. +* `phx-blur`: Fired when a specific native UI element loses focus, commonly used with input fields. +* `phx-click`: Fired when a user taps on a native UI element, enabling a response to tap events. + +> The above events work on all SwiftUI views. Some events are only available on specific views. For example, `phx-change` is available on controls and `phx-throttle/phx-debounce` is available on views with events. + +There is also a [Pull Request](https://github.com/liveview-native/liveview-client-swiftui/issues/1095) to add Key Events which may have been merged since this guide was published. + +## Basic Click Example + +The `phx-click` event triggers a corresponding [handle_event/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_event/3) callback function whenever a SwiftUI view is pressed. + +In the example below, the client sends a `"ping"` event to the server, and trigger's the LiveView's `"ping"` event handler. + +Evaluate the example below, then click the `"Click me!"` button. Notice `"Pong"` printed in the server logs below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("ping", _params, socket) do + IO.puts("Pong") + {:noreply, socket} + end +end +``` + +### Click Events Updating State + +Event handlers in LiveView can update the LiveView's state in the socket. + +Evaluate the cell below to see an example of incrementing a count. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end +end +``` + +### Your Turn: Decrement Counter + +You're going to take the example above, and create a counter that can **both increment and decrement**. + +There should be two buttons, each with a `phx-click` binding. One button should bind the `"decrement"` event, and the other button should bind the `"increment"` event. Each event should have a corresponding handler defined using the `handle_event/3` callback function. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <%= @count %> + + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_event("decrement", _params, socket) do + {:noreply, assign(socket, :count, socket.assigns.count - 1)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <%= @count %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :count, 0)} + end + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Selectable Lists + +`List` views support selecting items within the list based on their id. To select an item, provide the `selection` attribute with the item's id. + +Pressing a child item in the `List` on a native device triggers the `phx-change` event. In the example below we've bound the `phx-change` event to send the `"selection-changed"` event. This event is then handled by the `handle_event/3` callback function and used to change the selected item. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Item <%= i %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, selection: "None")} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + +## Expandable Lists + +`List` views support hierarchical content using the [DisclosureGroup](https://developer.apple.com/documentation/swiftui/disclosuregroup) view. Nest `DisclosureGroup` views within a list to create multiple levels of content as seen in the example below. + +To control a `DisclosureGroup` view, use the `isExpanded` boolean attribute as seen in the example below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Level 1 + Item 1 + Item 2 + Item 3 + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :is_expanded, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"isExpanded" => is_expanded}, socket) do + {:noreply, assign(socket, is_expanded: is_expanded)} + end +end +``` + +### Multiple Expandable Lists + +The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Level 1 + Item 1 + + Level 2 + Item 2 + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle-" <> level, %{"isExpanded" => is_expanded}, socket) do + level = String.to_integer(level) + + {:noreply, + assign( + socket, + :expanded_groups, + Map.replace!(socket.assigns.expanded_groups, level, is_expanded) + )} + end +end +``` + +## Forms + +In Phoenix, form elements must be inside of a form. Phoenix only captures events if the element is wrapped in a form. However in SwiftUI there is no similar concept of forms. To bridge the gap, we built the [LiveView Native Live Form](https://github.com/liveview-native/liveview-native-live-form) library. This library provides several views to enable writing views in a single form. + +Phoenix Applications setup with LiveView native include a `core_components.ex` file. This file contains several components for building forms. Generally, We recommend using core components rather than the views. We're going to cover the views directly so you understand how to build forms from scratch and how we built the core components. However, in the [Forms and Validations](https://hexdocs.pm/live_view_native/forms-and-validation.html) reading we'll cover using core components. + + + +### LiveForm + +The `LiveForm` view must wrap views to capture events from the `phx-change` or `phx-submit` event. The `phx-change` event sends a message to the LiveView anytime the control or indicator changes its value. The `phx-submit` event sends a message to the LiveView when a user clicks the `LiveSubmitButton`. The params of the message are based on the name of the [Binding](https://developer.apple.com/documentation/swiftui/binding) argument of the view's initializer in SwiftUI. + +Here's some example boilerplate for a `LiveForm`. The `id` attribute is required. + +```html + + + Button Text + +``` + + + +### Basic Example using TextField + +The following example shows you how to connect a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) with a `phx-change` event and `phx-submit` binding to a corresponding event handler. + +Evaluate the example below. Type into the text field and press submit on your iOS simulator. Notice the inspected `params` appear in the server logs in the console below as a map of `%{"my-input" => value}` based on the `name` attribute on the `TextField` view. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Enter text here + Submit + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + require Logger + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change", params, socket) do + Logger.info("Change params: #{inspect(params)}") + {:noreply, socket} + end + + @impl true + def handle_event("submit", params, socket) do + Logger.info("Submitted params: #{inspect(params)}") + {:noreply, socket} + end +end +``` + +### Event Handlers + +The `phx-change` and `phx-submit` event handlers should generally be bound to the LiveForm. However, you can also bind the event handlers directly to the input view if you want to separately handle a single view's change events. + + + +```elixir + + Enter text here + Submit + +``` + +## Controls and Indicators + +SwiftUI organizes interactive views in the [Controls and Indicators](https://developer.apple.com/documentation/swiftui/controls-and-indicators) section. You may refer to this documentation when looking for views that belong within a form. + +We'll demonstrate how to work with a few common control and indicator views. + + + +### Slider + +This code example renders a SwiftUI [Slider](https://developer.apple.com/documentation/swiftui/slider). It triggers the change event when the slider is moved and sends a `"slide"` message. The `"slide"` event handler then logs the value to the console. + +The [Slider](https://developer.apple.com/documentation/swiftui/slider) view uses **named content areas** `minumumValueLabel` and `maximumValueLabel`. The example below demonstrates how to represent these areas using the `template` attribute. + +This example also demonstrates how to use the params sent by the slider to store a value in the socket and use it elsewhere in the template. + +Evaluate the example and enter some text in your iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + 0% + 100% + + + <%= @percentage %> + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :percentage, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("slide", %{"my-slider" => value}, socket) do + {:noreply, assign(socket, :percentage, value)} + end +end +``` + +### Stepper + +This code example renders a SwiftUI [Stepper](https://developer.apple.com/documentation/swiftui/stepper). It triggers the change event and sends a `"change-tickets"` message when the stepper increments or decrements. The `"change-tickets"` event handler then updates the number of tickets stored in state, which appears in the UI. + +Evaluate the example and increment/decrement the step. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Tickets <%= @tickets %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :tickets, 0)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("change-tickets", %{"my-stepper" => tickets}, socket) do + {:noreply, assign(socket, :tickets, tickets)} + end +end +``` + +### Toggle + +This code example renders a SwiftUI [Toggle](https://developer.apple.com/documentation/swiftui/toggle). It triggers the change event and sends a `"toggle"` message when toggled. The `"toggle"` event handler then updates the `:on` field in state, which allows the `Toggle` view to be toggled o through the `isOn` attribute. + +Evaluate the example below and click on the toggle. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + On/Off + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :on, false)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("toggle", %{"my-toggle" => on}, socket) do + {:noreply, assign(socket, :on, on)} + end +end +``` + +### DatePicker + +The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. Evaluate the example below and select a date to see the date params appear in the console below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + require Logger + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :date, nil)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", params, socket) do + Logger.info("Date Params: #{inspect(params)}") + {:noreply, socket} + end +end +``` + +### Parsing Dates + +The date from the `DatePicker` is in iso8601 format. You can use the `from_iso8601` function to parse this string into a `DateTime` struct. + +```elixir +iso8601 = "2024-01-17T20:51:00.000Z" + +DateTime.from_iso8601(iso8601) +``` + +### Your Turn: Displayed Components + +The `DatePicker` view accepts a `displayedComponents` attribute with the value of `"hourAndMinute"` or `"date"` to only display one of the two components. By default, the value is `"all"`. + +You're going to change the `displayedComponents` attribute in the example below to see both of these options. Change `"all"` to `"date"`, then to `"hourAndMinute"`. Re-evaluate the cell between changes and see the updated UI. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("pick-date", _params, socket) do + {:noreply, socket} + end +end +``` + +## Small Project: Todo List + +Using the previous examples as inspiration, you're going to create a todo list. + +**Requirements** + +* Items should be `Text` views rendered within a `List` view. +* Item ids should be stored in state as a list of integers i.e. `[1, 2, 3, 4]` +* Use a `TextField` to provide the name of the next added todo item. +* An add item `Button` should add items to the list of integers in state when pressed. +* A delete item `Button` should remove the currently selected item from the list of integers in state when pressed. + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Todo... + + + + + <%= content %> + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)} + end + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("type-name", %{"text" => name}, socket) do + {:noreply, assign(socket, :item_name, name)} + end + + def handle_event("add-item", _params, socket) do + updated_items = [ + {"item-#{socket.assigns.next_item_id}", socket.assigns.item_name} + | socket.assigns.items + ] + + {:noreply, + assign(socket, + item_name: "", + items: updated_items, + next_item_id: socket.assigns.next_item_id + 1 + )} + end + + def handle_event("delete-item", _params, socket) do + updated_items = + Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end) + {:noreply, assign(socket, :items, updated_items)} + end + + def handle_event("selection-changed", %{"selection" => selection}, socket) do + {:noreply, assign(socket, selection: selection)} + end +end +``` + + + + + +### Enter Your Solution Below + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + # Define your mount/3 callback + + @impl true + def render(assigns), do: ~H"" + + # Define your render/3 callback + + # Define any handle_event/3 callbacks +end +``` diff --git a/livebooks/markdown/native-navigation.md b/livebooks/markdown/native-navigation.md new file mode 100644 index 000000000..fe1af5620 --- /dev/null +++ b/livebooks/markdown/native-navigation.md @@ -0,0 +1,390 @@ +# Native Navigation + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%native-navigation.livemd) + +## Overview + +This guide will teach you how to create multi-page applications using LiveView Native. We will cover navigation patterns specific to native applications and how to reuse the existing navigation patterns available in LiveView. + +Before diving in, you should have a basic understanding of navigation in LiveView. You should be familiar with the [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions, which are used to trigger navigation from within a LiveView. Additionally, you should know how to define routes in the router using the [live/4](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4) macro. + +## NavigationStack + +LiveView Native applications are generally wrapped in a [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view. This view usually exists in the `root.swiftui.heex` file, which looks something like the following: + + + +```elixir +<.csrf_token /> + + +
+ Hello, from LiveView Native! +
+
+``` + +Notice the [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view wraps the template. This view manages the state of navigation history and allows for navigating back to previous pages. + +## Navigation Links + +We can use the [NavigationLink](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/navigationlink) view for native navigation, similar to how we can use the [.link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with the `navigate` attribute for web navigation. + +We've created the same example of navigating between the `Main` and `About` pages. Each page using a `NavigationLink` to navigate to the other page. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + + To about + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + To home + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +The `destination` attribute works the same as the `navigate` attribute on the web. The current LiveView will shut down, and a new one will mount without re-establishing a new socket connection. + +## Link Component + +The [link](https://github.com/liveview-native/liveview-client-swiftui/blob/748389d11007503273a96d28c3f0915ee68584bb/lib/live_view_native/swiftui/component.ex#L196) component wraps the `NavigationLink` and `Link` view. It accepts both the `navigation` and `href` attributes depending on the type of navigation you want to trigger. `navigation` preserves the socket connection and is best used for navigation within the application. `href` uses the `Link` view to navigate to an external resource using the native browser. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + <.link navigate="about" >To about + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + <.link navigate="home" >To home + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +The `href` attribute is best used for external sites that the device will open in the native browser. Evaluate the example below and click the link to navigate to https://www.google.com. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.link href="https://www.google.com">To Google + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Push Navigation + +For LiveView Native views, we can still use the same [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2), and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions used in typical LiveViews. + +These functions are preferable over `NavigationLink` views when you want to share navigation handlers between web and native, and/or when you want to have more customized navigation handling. + +Evaluate **both** of the code cells below and click on the `Button` view in your simulator that triggers the `handle_event/3` navigation handler to navigate between the two views. + + + +```elixir +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-about", _params, socket) do + {:noreply, push_navigate(socket, to: "/about")} + end +end +``` + + + +```elixir +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-main", _params, socket) do + {:noreply, push_navigate(socket, to: "/")} + end +end +``` + +## Routing + +The `KinoLiveViewNative` smart cells used in this guide automatically define routes for us. Be aware there is no difference between how we define routes for LiveView or LiveView Native. + +The routes for the main and about pages might look like the following in the router: + + + +```elixir +live "/", Server.MainLive +live "/about", Server.AboutLive +``` + +## Native Navigation Events + +LiveView Native navigation mirrors the same navigation behavior you'll find on the web. + +Evaluate the example below and press each button. Notice that: + +1. `redirect/2` triggers the `mount/3` callback and re-establishes a socket connection. +2. `push_navigate/2` triggers the `mount/3` callback and re-uses the existing socket connection. +3. `push_patch/2` does not trigger the `mount/3` callback, but does trigger the `handle_params/3` callback. This is often useful when using navigation to trigger page changes such as displaying a modal or overlay. + +You can see this for yourself using the following example. Click each of the buttons for redirect, navigate, and patch behavior. Try to understand each navigation type, and which callback functions the navigation type triggers. + + + +```elixir +# This module built for example purposes to persist logs between mounting LiveViews. +defmodule PersistantLogs do + def get do + :persistent_term.get(:logs) + end + + def put(log) when is_binary(log) do + :persistent_term.put(:logs, [{log, Time.utc_now()} | get()]) + end + + def reset do + :persistent_term.put(:logs, []) + end +end + +PersistantLogs.reset() + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + + + Socket ID<%= @socket_id %> + LiveView PID:<%= @live_view_pid %> + <%= for {log, time} <- Enum.reverse(@logs) do %> + + <%= Calendar.strftime(time, "%H:%M:%S") %>: + <%= log %> + + <% end %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + PersistantLogs.put("MOUNT") + + {:ok, + assign(socket, + socket_id: socket.id, + connected: connected?(socket), + logs: PersistantLogs.get(), + live_view_pid: inspect(self()) + )} + end + + @impl true + def handle_params(_params, _url, socket) do + PersistantLogs.put("HANDLE PARAMS") + + {:noreply, assign(socket, :logs, PersistantLogs.get())} + end + + @impl true + def render(assigns), + do: ~H""" + + """ + + def handle_event("do-thing", _params, socket) do + IO.inspect("DOING THING") + {:noreply, socket} + end + + @impl true + def handle_event("redirect", _params, socket) do + PersistantLogs.reset() + PersistantLogs.put("--REDIRECTING--") + {:noreply, redirect(socket, to: "/")} + end + + def handle_event("navigate", _params, socket) do + PersistantLogs.put("---NAVIGATING---") + {:noreply, push_navigate(socket, to: "/")} + end + + def handle_event("patch", _params, socket) do + PersistantLogs.put("----PATCHING----") + {:noreply, push_patch(socket, to: "/")} + end +end +``` + +```elixir +dle +``` diff --git a/livebooks/markdown/stylesheets.md b/livebooks/markdown/stylesheets.md new file mode 100644 index 000000000..407db771d --- /dev/null +++ b/livebooks/markdown/stylesheets.md @@ -0,0 +1,542 @@ +# Stylesheets + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%stylesheets.livemd) + +## Overview + +In this guide, you'll learn how to use stylesheets to customize the appearance of your LiveView Native Views. You'll also learn about the inner workings of how LiveView Native uses stylesheets to implement modifiers, and how those modifiers style and customize SwiftUI Views. By the end of this lesson, you'll have the fundamentals you need to create beautiful native UIs. + +## The Stylesheet AST + +LiveView Native parses through your application at compile time to create a stylesheet AST representation of all the styles in your application. This stylesheet AST is used by the LiveView Native Client application when rendering the view hierarchy to apply modifiers to a given view. + +```mermaid +sequenceDiagram + LiveView->>LiveView: Create stylesheet + Client->>LiveView: Send request to "http://localhost:4000/?_format=swiftui" + LiveView->>Client: Send LiveView Native template in response + Client->>LiveView: Send request to "http://localhost:4000/assets/app.swiftui.styles" + LiveView->>Client: Send stylesheet in response + Client->>Client: Parses stylesheet into SwiftUI modifiers + Client->>Client: Apply modifiers to the view hierarchy +``` + +We've setup this Livebook to be included when parsing the application for modifiers. You can visit http://localhost:4000/assets/app.swiftui.styles to see the Stylesheet AST created by all of the styles in this Livebook and any other styles used in the `kino_live_view_native` project. + +LiveView Native watches for changes and updates the stylesheet, so those will be dynamically picked up and applied, You may notice a slight delay as the Livebook takes **5 seconds** to write its contents to a file. + +## Modifiers + +SwiftUI employs **modifiers** to style and customize views. In SwiftUI syntax, each modifier is a function that can be chained onto the view they modify. LiveView Native has a minimal DSL (Domain Specific Language) for writing SwiftUI modifiers. + +Modifers can be applied through a LiveView Native Stylesheet and applying them through inline styles as described in the [LiveView Native Stylesheets](#liveview-native-stylesheets) section, or can be applied directly through the `style` attribute as described in the [Utility Styles](#utility-styles) section. + + + +### SwiftUI Modifiers + +Here's a basic example of making text red using the [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) modifier. + +```swift +Text("Some Red Text") + .foregroundStyle(.red) +``` + +Many modifiers can be applied to a view. Here's an example using [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) and [frame](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)). + +```swift +Text("Some Red Text") + .foregroundStyle(.red) + .font(.title) +``` + + + +### Implicit Member Expression + +Implicit Member Expression in SwiftUI means that we can implicityly access a member of a given type without explicitly specifying the type itself. For example, the `.red` value above is from the [Color](https://developer.apple.com/documentation/swiftui/color) structure. + +```swift +Text("Some Red Text") + .foregroundStyle(Color.red) +``` + + + +### LiveView Native Modifiers + +The DSL (Domain Specific Language) used in LiveView Native drops the `.` dot before each modifier, but otherwise remains largely the same. We do not document every modifier separately, since you can translate SwiftUI examples into the DSL syntax. + +For example, Here's the same `foregroundStyle` modifier as it would be written in a LiveView Native stylesheet or style attribute, which we'll cover in a moment. + +```swift +foregroundStyle(.red) +``` + +There are some exceptions where the DSL differs from SwiftUI syntax, which we'll cover in the sections below. + +## Utility Styles + +In addition to introducing stylesheets, LiveView Native `0.3.0` also introduced Utility styles, which will be our prefered method for writing styles in these Livebook guides. + +Utility styles are comperable to inline styles in HTML, which have been largely discouraged in the CSS community. We recommend Utility styles for now as the easiest way to prototype applications. However, we hope to replace Utility styles with a more mature styling framework in the future. + +The same SwiftUI syntax used inside of a stylesheet can be used directly inside of a `style` attribute. The example below defines the `foregroundStyle(.red)` modifier. Evaluate the example and view it in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Multiple Modifiers + +You can write multiple modifiers separated by a semi-color `;`. + +```heex +Hello, from LiveView Native! +``` + +To include newline characters in your string wrap the string in curly brackets `{}`. Using multiple lines can better organize larger amounts of modifiers. + +```heex + +Hello, from LiveView Native! + +``` + +## Dynamic Style Names + +LiveView Native parses styles in your project to define a single stylesheet. You can find the AST representation of this stylesheet at http://localhost:4000/assets/app.swiftui.styles. This stylesheet is compiled on the server and then sent to the client. For this reason, class names must be fully-formed. For example, the following style using string interpolation is **invalid**. + +```heex + +Invalid Example + +``` + +However, we can still use dynamic styles so long as the modifiers are fully formed. + +```heex + +Red or Blue Text + +``` + +Evaluate the example below multiple times while watching your simulator. Notice that the text is dynamically red or blue. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Modifier Order + +Modifier order matters. Changing the order that modifers are applied can have a significant impact on their behavior. + +To demonstrate this concept, we're going to take a simple example of applying padding and background color. + +If we apply the background color first, then the padding, The background is applied to original view, leaving the padding filled with whitespace. + + + +```elixir +background(.orange) +padding(20) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style View fill:orange +``` + +If we apply the padding first, then the background, the background is applied to the view with the padding, thus filling the entire area with background color. + + + +```elixir +padding(20) +background(.orange) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style Padding fill:orange +style View fill:orange +``` + +Evaluate the example below to see this in action. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +## Custom Colors + +### SwiftUI Color Struct + +The SwiftUI [Color](https://developer.apple.com/documentation/swiftui/color) structure accepts either the name of a color in the asset catalog or the RGB values of the color. + +Therefore we can define custom RBG styles like so: + +```swift +foregroundStyle(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) +``` + +Evaluate the example below to see the custom color in your simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +``` + +### Custom Colors in the Asset Catalogue + +Custom colors can be defined in the [Asset Catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Once defined in the asset catalogue of the Xcode application, the color can be referenced by name like so: + +```swift +foregroundStyle(Color("MyColor")) +``` + +Generally using the asset catalog is more performant and customizable than using custom RGB colors with the [Color](https://developer.apple.com/documentation/swiftui/color) struct. + + + +### Your Turn: Custom Colors in the Asset Catalog + +Custom colors can be defined in the asset catalog (https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). You're going to define a color in the asset catolog then evaluate the example below to see the color appear in your simulator. + +To create a new color go to the `Assets` folder in your iOS app and create a new color set. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-create-new-color-set.png?raw=true) + + + +To create a color set, enter the RGB values or a hexcode as shown in the image below. If you don't see the sidebar with color options, click the icon in the top-right of your Xcode app and click the **Show attributes inspector** icon shown highlighted in blue. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-modify-my-color.png?raw=true) + + + +The defined color is now available for use within LiveView Native styles. However, the app needs to be re-compiled to pick up a new color set. + +Re-build your SwiftUI Application before moving on. Then evaluate the code below. You should see your custom colored text in the simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"Hello" +end +``` + +## LiveView Native Stylesheets + +In LiveView Native, we use `~SHEET` sigil stylesheets to organize modifers by classes using an Elixir-oriented DSL similar to CSS for styling web elements. + +We group modifiers together within a class that can be applied to an element. Here's an example of how modifiers can be grouped into a "red-title" class in a stylesheet: + + + +```elixir +~SHEET""" + "red-title" do + foregroundColor(.red); + font(.title); + end +""" +``` + +We're mostly using Utility styles for these guides, but the stylesheet module does contain some important configuration to `@import` the utility styles module. It can also be used to group styles within a class if you have a set of modifiers you're repeatedly using and want to group together. + + + +```elixir +defmodule ServerWeb.Styles.App.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + @import LiveViewNative.SwiftUI.UtilityStyles + + ~SHEET""" + "red-title" do + foregroundColor(.red); + font(.title); + end + """ +end +``` + +You can apply these classes through the `class` attribute. + +```heex +Red Title Text +``` + +## Injecting Views in Stylesheets + +SwiftUI modifiers sometimes accept SwiftUI views as arguments. Here's an example using the `clipShape` modifier with a `Circle` view. + +```swift +Image("logo") + .clipShape(Circle()) +``` + +However, LiveView Native does not support using SwiftUI views directly within a stylesheet. Instead, we have a few alternative options in cases like this where we want to use a view within a modifier. + + + +### Using Members on a Given Type + +We can't use the [Circle](https://developer.apple.com/documentation/swiftui/circle) view directly. However, the [Getting standard shapes](https://developer.apple.com/documentation/swiftui/shape#getting-standard-shapes) documentation describes methods for accessing standard shapes. For example, we can use `Circle.circle` for the circle shape. + +We can use `Circle.circle` instead of the `Circle` view. So, the following code is equivalent to the example above. + +```swift +Image("logo") + .clipShape(Circle.circle) +``` + +However, in LiveView Native we only support using implicit member expression syntax, so instead of `Circle.circle`, we only write `.circle`. + +```swift +Image("logo") + .clipShape(.circle) +``` + +Which is simple to convert to the LiveView Native DSL using the rules we've already learned. + + + +```elixir +"example-class" do + clipShape(.circle) +end +``` + + + +### Injecting a View + +For more complex cases, we can inject a view directly into a stylesheet. + +Here's an example where this might be useful. SwiftUI has modifers that represent a named content area for views to be placed within. These views can even have their own modifiers, so it's not enough to use a simple static property on the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. + +```swift +Image("logo") + .overlay(content: { + Circle().stroke(.red, lineWidth: 4) + }) +``` + +To get around this issue, we instead inject a view into the stylesheet. First, define the modifier and use an atom to represent the view that's going to be injected. + + + +```elixir +"overlay-circle" do + overlay(content: :circle) +end +``` + +Then use the `template` attribute on the view to be injected into the stylesheet. + +```heex + + + +``` + +## Apple Documentation + +You can find documentation and examples of modifiers on [Apple's SwiftUI documentation](https://developer.apple.com/documentation/swiftui) which is comprehensive and thorough, though it may feel unfamiliar at first for Elixir Developers when compared to HexDocs. + + + +### Finding Modifiers + +The [Configuring View Elements](https://developer.apple.com/documentation/swiftui/view#configuring-view-elements) section of apple documentation contains links to modifiers organized by category. In that documentation you'll find useful references such as [Style Modifiers](https://developer.apple.com/documentation/swiftui/view-style-modifiers), [Layout Modifiers](https://developer.apple.com/documentation/swiftui/view-layout), and [Input and Event Modifiers](https://developer.apple.com/documentation/swiftui/view-input-and-events). + +You can also find more on modifiers with LiveView Native examples on the [liveview-client-swiftui](https://hexdocs.pm/live_view_native_swiftui) HexDocs. + +## Visual Studio Code Extension + +If you use Visual Studio Code, we strongly recommend you install the [LiveView Native Visual Studio Code Extension](https://github.com/liveview-native/liveview-native-vscode) which provides autocompletion and type information thus making modifiers significantly easier to write and lookup. + +## Your Turn: Syntax Conversion + +Part of learning LiveView Native is learning SwiftUI. Fortunately we can leverage the existing SwiftUI ecosystem and convert examples into LiveView Native syntax. + +You're going to convert the following SwiftUI code into a LiveView Native template. This example is inspired by the official [SwiftUI Tutorials](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + +```elixir +VStack(alignment: .leading) { + Text("Turtle Rock") + .font(.title) + HStack { + Text("Joshua Tree National Park") + Spacer() + Text("California") + } + .font(.subheadline) + + Divider() + + Text("About Turtle Rock") + .font(.title2) + Text("Descriptive text goes here") +} +.padding() +``` + +### Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Turtle Rock + + Joshua Tree National Park + + California + + + About Turtle Rock + Descriptive text goes here + + """ + end +end +``` + + + +Enter your solution below. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end +``` diff --git a/livebooks/markdown/swiftui-views.md b/livebooks/markdown/swiftui-views.md new file mode 100644 index 000000000..67174e86b --- /dev/null +++ b/livebooks/markdown/swiftui-views.md @@ -0,0 +1,95 @@ +# SwiftUI Views + +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Fliveview-native%liveview-client-swiftui%2Fmain%2Flivebooks%swiftui-views.livemd) + +## Overview + +LiveView Native aims to use minimal SwiftUI code and instead rely on the same patterns used in traditional Phoenix LiveView development as much as possible. We'll primarily use The LiveView Naive SwiftUI DSL (Domain Specific Language) to build the native template. + +This lesson will teach you how to build SwiftUI templates using common SwiftUI views. We'll cover common use cases and provide practical examples of how to build native UIs. This lesson is like a recipe book you can refer to whenever you need an example of a particular SwiftUI view. + +In addition, we'll cover the LiveView Native DSL and teach you how to convert SwiftUI examples into the LiveView Native DSL. Once you understand how to convert SwiftUI code into the LiveView Native DSL, you'll have the knowledge you need to learn from the plethora of [SwiftUI resources available](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + + + +```elixir +Hamlet +``` + +## Render Components + +LiveView Native `0.3.0` introduced render components to encourage isolation of native and web templates. This pattern generally scales better than co-located templates within the same LiveView module. + +Render components are namespaced under the main LiveView, and are responsible for defining the `render/1` callback function that returns the native template. + +For example, in the cell below, the `ExampleLive` LiveView module has a corresponding `ExampleLive.SwiftUI` render component module for the native template. This `ExampleLive.SwiftUI` render component may define a `render/1` callback function, as seen below. + + + +```elixir +# Render Component +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +# LiveView +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +``` + +Throughout this and further material, we'll re-define render components you can evaluate and see reflected in your Xcode iOS simulator. + + + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from a LiveView Native Render Component! + """ + end +end +``` + +In a Phoenix application, these two modules would traditionally be in separate files. + + + +### Embedding Templates + +Instead of defining a `render/1` callback function, you may instead define a `.neex` (Native + Embedded Elixir) template. + +By default, the module above would look for a template in the `swiftui/example_live*` path relative to the module's location. You can see the `LiveViewNative.Component` documentation for further explanation. + +In Livebook, we'll use the `render/1` callback. However, we recommend using template files for local Phoenix + LiveView Native applications. + +## SwiftUI Views + +In SwiftUI, a "View" is like a building block for what you see on your app's screen. It can be something simple like text, or something more complex like a layout with multiple elements. + +Here's an example `Text` view that represents a text element. + +```swift +Text("Hamlet") +``` + +LiveView Native uses the following syntax to represent the view above. diff --git a/livebooks/native-navigation.livemd b/livebooks/native-navigation.livemd new file mode 100644 index 000000000..47d80f565 --- /dev/null +++ b/livebooks/native-navigation.livemd @@ -0,0 +1,531 @@ +# Native Navigation + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +This guide will teach you how to create multi-page applications using LiveView Native. We will cover navigation patterns specific to native applications and how to reuse the existing navigation patterns available in LiveView. + +Before diving in, you should have a basic understanding of navigation in LiveView. You should be familiar with the [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2) and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions, which are used to trigger navigation from within a LiveView. Additionally, you should know how to define routes in the router using the [live/4](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4) macro. + +## NavigationStack + +LiveView Native applications are generally wrapped in a [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view. This view usually exists in the `root.swiftui.heex` file, which looks something like the following: + + + +```elixir +<.csrf_token /> + + +
+ Hello, from LiveView Native! +
+
+``` + +Notice the [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack) view wraps the template. This view manages the state of navigation history and allows for navigating back to previous pages. + +## Navigation Links + +We can use the [NavigationLink](https://liveview-native.github.io/liveview-client-swiftui/documentation/liveviewnative/navigationlink) view for native navigation, similar to how we can use the [.link](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#link/1) component with the `navigate` attribute for web navigation. + +We've created the same example of navigating between the `Main` and `About` pages. Each page using a `NavigationLink` to navigate to the other page. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + + To about + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + To home + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/about") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +The `destination` attribute works the same as the `navigate` attribute on the web. The current LiveView will shut down, and a new one will mount without re-establishing a new socket connection. + +## Link Component + +The [link](https://github.com/liveview-native/liveview-client-swiftui/blob/748389d11007503273a96d28c3f0915ee68584bb/lib/live_view_native/swiftui/component.ex#L196) component wraps the `NavigationLink` and `Link` view. It accepts both the `navigation` and `href` attributes depending on the type of navigation you want to trigger. `navigation` preserves the socket connection and is best used for navigation within the application. `href` uses the `Link` view to navigate to an external resource using the native browser. + +Evaluate **both** of the code cells below and click on the `NavigationLink` in your simulator to navigate between the two views. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + <.link navigate="about" >To about + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + <.link navigate="home" >To home + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +The `href` attribute is best used for external sites that the device will open in the native browser. Evaluate the example below and click the link to navigate to https://www.google.com. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + <.link href="https://www.google.com">To Google + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Push Navigation + +For LiveView Native views, we can still use the same [redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#redirect/2), [push_patch/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_patch/2), and [push_navigate/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2) functions used in typical LiveViews. + +These functions are preferable over `NavigationLink` views when you want to share navigation handlers between web and native, and/or when you want to have more customized navigation handling. + +Evaluate **both** of the code cells below and click on the `Button` view in your simulator that triggers the `handle_event/3` navigation handler to navigate between the two views. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.HomeLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the home page + + """ + end +end + +defmodule ServerWeb.HomeLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-about", _params, socket) do + {:noreply, push_navigate(socket, to: "/about")} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.AboutLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + You are on the about page + + """ + end +end + +defmodule ServerWeb.AboutLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" + + @impl true + def handle_event("to-main", _params, socket) do + {:noreply, push_navigate(socket, to: "/")} + end +end +|> Server.SmartCells.LiveViewNative.register("/about") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Routing + +The `KinoLiveViewNative` smart cells used in this guide automatically define routes for us. Be aware there is no difference between how we define routes for LiveView or LiveView Native. + +The routes for the main and about pages might look like the following in the router: + + + +```elixir +live "/", Server.MainLive +live "/about", Server.AboutLive +``` + +## Native Navigation Events + +LiveView Native navigation mirrors the same navigation behavior you'll find on the web. + +Evaluate the example below and press each button. Notice that: + +1. `redirect/2` triggers the `mount/3` callback and re-establishes a socket connection. +2. `push_navigate/2` triggers the `mount/3` callback and re-uses the existing socket connection. +3. `push_patch/2` does not trigger the `mount/3` callback, but does trigger the `handle_params/3` callback. This is often useful when using navigation to trigger page changes such as displaying a modal or overlay. + +You can see this for yourself using the following example. Click each of the buttons for redirect, navigate, and patch behavior. Try to understand each navigation type, and which callback functions the navigation type triggers. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +# This module built for example purposes to persist logs between mounting LiveViews. +defmodule PersistantLogs do + def get do + :persistent_term.get(:logs) + end + + def put(log) when is_binary(log) do + :persistent_term.put(:logs, [{log, Time.utc_now()} | get()]) + end + + def reset do + :persistent_term.put(:logs, []) + end +end + +PersistantLogs.reset() + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + + + + Socket ID<%= @socket_id %> + LiveView PID:<%= @live_view_pid %> + <%= for {log, time} <- Enum.reverse(@logs) do %> + + <%= Calendar.strftime(time, "%H:%M:%S") %>: + <%= log %> + + <% end %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def mount(_params, _session, socket) do + PersistantLogs.put("MOUNT") + + {:ok, + assign(socket, + socket_id: socket.id, + connected: connected?(socket), + logs: PersistantLogs.get(), + live_view_pid: inspect(self()) + )} + end + + @impl true + def handle_params(_params, _url, socket) do + PersistantLogs.put("HANDLE PARAMS") + + {:noreply, assign(socket, :logs, PersistantLogs.get())} + end + + @impl true + def render(assigns), + do: ~H""" + + """ + + def handle_event("do-thing", _params, socket) do + IO.inspect("DOING THING") + {:noreply, socket} + end + + @impl true + def handle_event("redirect", _params, socket) do + PersistantLogs.reset() + PersistantLogs.put("--REDIRECTING--") + {:noreply, redirect(socket, to: "/")} + end + + def handle_event("navigate", _params, socket) do + PersistantLogs.put("---NAVIGATING---") + {:noreply, push_navigate(socket, to: "/")} + end + + def handle_event("patch", _params, socket) do + PersistantLogs.put("----PATCHING----") + {:noreply, push_patch(socket, to: "/")} + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +```elixir +dle +``` diff --git a/livebooks/navigation_cookbook.livemd b/livebooks/navigation_cookbook.livemd new file mode 100644 index 000000000..a4623b20f --- /dev/null +++ b/livebooks/navigation_cookbook.livemd @@ -0,0 +1,184 @@ +# Navigation Cookbook + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +Cookbooks are succinct code examples demonstrating a single concept. This cookbook covers [modern iOS navigation patterns](https://frankrausch.com/ios-navigation) inspired by Frank Rausch and how to implement them in an iOS application using LiveView Native. + +## Structural Navigation + +Structural navigation in iOS development refers to the way in which users move through different views or screens within an app. It encompasses the techniques and design patterns used to manage and transition between views, ensuring a coherent and intuitive user experience. + +> A typical iOS application has a fixed architecture—often a hierarchical tree with multiple levels. This rigid structure makes navigation options predictable. Structural navigation patterns give users confidence about where they came from, where they are in the hierarchy, and how to navigate back to where they came from. +> +> * Frank Rausch + +There are four main structural navigation patterns. + +* **Drill-Down**: A hierarchy of views in a tree-like structure. Users can freely move back and forth between views up and down the view hierarchy. +* **Flat**: Independent peer-level views often implemented as tab-bar or sidebar navigation. +* **Pyramid**: Horizontal navigation between views on the same hierarchy often implemented through left and right swipe navigation. +* **Hub and Spoke**: Navigation through a central hub (main menu) to access various spokes (individual features or sections). + +## Drill-Down Navigation + +Drill-down navigation is often the "default" navigation. Users navigate freely between pages and go deeper into the navigation hierarchy to access nested pages, or return to the page they were previously on (typically through swiping left, or through a back button). + +```mermaid +flowchart +P1[Page 1] +P2[Page 2] +P3[Page 3] +P4[Page 4] +P5[Page 5] +P6[Page 6] + +P1 <--> P2 +P1 <--> P3 +P3 <--> P4 +P3 <--> P5 +P5 <--> P6 +``` + +Users can often access the same page through multiple different origin points in the navigation hierarchy. For example, in a Spotify application, if you were looking for a particular song, you could find it through the artist page, or through the genre page. + +```mermaid +flowchart +Home <--> Artist +Home <--> Genre +Artist <--> Album --> Song +Genre <--> Song +``` + +### Example + +Here's an example of how to implement drill-down navigation in LiveView Native. + +This example uses a single shared route you can visit at http://localhost:4000/ and query params to create the following simple hierarchy of pages. + +```mermaid +flowchart +Home +TS[Taylor Swift] +ED[Ed Sheeran] + +Artists <--> P1 +Artists <--> P2 +``` + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + <.link + :for={i <- 1..10} + navigate={~p"/?index=#{i}"} + > + Item <%= i %> + + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + def mount(%{"index" => index}, _session, socket) do + {:ok, assign(socket, title: "Page #{index}")} + end + + def mount(params, _session, socket) do + {:ok, assign(socket, title: "Drill Down")} + end + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +For the sake of simplicity, this example uses query params to render each page. However, there are multiple implementation options. For example, you could build multiple pages using multiple LiveViews instead of a single LiveView, and navigate to each page independently. + +Taking the previous example of artists, albums, and songs, the navigation hierarchy might looks something like the following + +``` +artist/:artist_id +album/:album_id +song/:song_id +``` diff --git a/livebooks/stylesheets.livemd b/livebooks/stylesheets.livemd new file mode 100644 index 000000000..6a8cdf19c --- /dev/null +++ b/livebooks/stylesheets.livemd @@ -0,0 +1,657 @@ +# Stylesheets + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +In this guide, you'll learn how to use stylesheets to customize the appearance of your LiveView Native Views. You'll also learn about the inner workings of how LiveView Native uses stylesheets to implement modifiers, and how those modifiers style and customize SwiftUI Views. By the end of this lesson, you'll have the fundamentals you need to create beautiful native UIs. + +## The Stylesheet AST + +LiveView Native parses through your application at compile time to create a stylesheet AST representation of all the styles in your application. This stylesheet AST is used by the LiveView Native Client application when rendering the view hierarchy to apply modifiers to a given view. + +```mermaid +sequenceDiagram + LiveView->>LiveView: Create stylesheet + Client->>LiveView: Send request to "http://localhost:4000/?_format=swiftui" + LiveView->>Client: Send LiveView Native template in response + Client->>LiveView: Send request to "http://localhost:4000/assets/app.swiftui.styles" + LiveView->>Client: Send stylesheet in response + Client->>Client: Parses stylesheet into SwiftUI modifiers + Client->>Client: Apply modifiers to the view hierarchy +``` + +We've setup this Livebook to be included when parsing the application for modifiers. You can visit http://localhost:4000/assets/app.swiftui.styles to see the Stylesheet AST created by all of the styles in this Livebook and any other styles used in the `kino_live_view_native` project. + +LiveView Native watches for changes and updates the stylesheet, so those will be dynamically picked up and applied, You may notice a slight delay as the Livebook takes **5 seconds** to write its contents to a file. + +## Modifiers + +SwiftUI employs **modifiers** to style and customize views. In SwiftUI syntax, each modifier is a function that can be chained onto the view they modify. LiveView Native has a minimal DSL (Domain Specific Language) for writing SwiftUI modifiers. + +Modifers can be applied through a LiveView Native Stylesheet and applying them through inline styles as described in the [LiveView Native Stylesheets](#liveview-native-stylesheets) section, or can be applied directly through the `style` attribute as described in the [Utility Styles](#utility-styles) section. + + + +### SwiftUI Modifiers + +Here's a basic example of making text red using the [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) modifier. + +```swift +Text("Some Red Text") + .foregroundStyle(.red) +``` + +Many modifiers can be applied to a view. Here's an example using [foregroundStyle](https://developer.apple.com/documentation/swiftui/text/foregroundstyle(_:)) and [frame](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)). + +```swift +Text("Some Red Text") + .foregroundStyle(.red) + .font(.title) +``` + + + +### Implicit Member Expression + +Implicit Member Expression in SwiftUI means that we can implicityly access a member of a given type without explicitly specifying the type itself. For example, the `.red` value above is from the [Color](https://developer.apple.com/documentation/swiftui/color) structure. + +```swift +Text("Some Red Text") + .foregroundStyle(Color.red) +``` + + + +### LiveView Native Modifiers + +The DSL (Domain Specific Language) used in LiveView Native drops the `.` dot before each modifier, but otherwise remains largely the same. We do not document every modifier separately, since you can translate SwiftUI examples into the DSL syntax. + +For example, Here's the same `foregroundStyle` modifier as it would be written in a LiveView Native stylesheet or style attribute, which we'll cover in a moment. + +```swift +foregroundStyle(.red) +``` + +There are some exceptions where the DSL differs from SwiftUI syntax, which we'll cover in the sections below. + +## Utility Styles + +In addition to introducing stylesheets, LiveView Native `0.3.0` also introduced Utility styles, which will be our prefered method for writing styles in these Livebook guides. + +Utility styles are comperable to inline styles in HTML, which have been largely discouraged in the CSS community. We recommend Utility styles for now as the easiest way to prototype applications. However, we hope to replace Utility styles with a more mature styling framework in the future. + +The same SwiftUI syntax used inside of a stylesheet can be used directly inside of a `style` attribute. The example below defines the `foregroundStyle(.red)` modifier. Evaluate the example and view it in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Multiple Modifiers + +You can write multiple modifiers separated by a semi-color `;`. + +```heex +Hello, from LiveView Native! +``` + +To include newline characters in your string wrap the string in curly brackets `{}`. Using multiple lines can better organize larger amounts of modifiers. + +```heex + +Hello, from LiveView Native! + +``` + +## Dynamic Style Names + +LiveView Native parses styles in your project to define a single stylesheet. You can find the AST representation of this stylesheet at http://localhost:4000/assets/app.swiftui.styles. This stylesheet is compiled on the server and then sent to the client. For this reason, class names must be fully-formed. For example, the following style using string interpolation is **invalid**. + +```heex + +Invalid Example + +``` + +However, we can still use dynamic styles so long as the modifiers are fully formed. + +```heex + +Red or Blue Text + +``` + +Evaluate the example below multiple times while watching your simulator. Notice that the text is dynamically red or blue. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello, from LiveView Native! + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Modifier Order + +Modifier order matters. Changing the order that modifers are applied can have a significant impact on their behavior. + +To demonstrate this concept, we're going to take a simple example of applying padding and background color. + +If we apply the background color first, then the padding, The background is applied to original view, leaving the padding filled with whitespace. + + + +```elixir +background(.orange) +padding(20) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style View fill:orange +``` + +If we apply the padding first, then the background, the background is applied to the view with the padding, thus filling the entire area with background color. + + + +```elixir +padding(20) +background(.orange) +``` + +```mermaid +flowchart + +subgraph Padding + View +end + +style Padding fill:orange +style View fill:orange +``` + +Evaluate the example below to see this in action. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## Custom Colors + +### SwiftUI Color Struct + +The SwiftUI [Color](https://developer.apple.com/documentation/swiftui/color) structure accepts either the name of a color in the asset catalog or the RGB values of the color. + +Therefore we can define custom RBG styles like so: + +```swift +foregroundStyle(Color(.sRGB, red: 0.4627, green: 0.8392, blue: 1.0)) +``` + +Evaluate the example below to see the custom color in your simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + Hello + + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +### Custom Colors in the Asset Catalogue + +Custom colors can be defined in the [Asset Catalogue](https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). Once defined in the asset catalogue of the Xcode application, the color can be referenced by name like so: + +```swift +foregroundStyle(Color("MyColor")) +``` + +Generally using the asset catalog is more performant and customizable than using custom RGB colors with the [Color](https://developer.apple.com/documentation/swiftui/color) struct. + + + +### Your Turn: Custom Colors in the Asset Catalog + +Custom colors can be defined in the asset catalog (https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs). You're going to define a color in the asset catolog then evaluate the example below to see the color appear in your simulator. + +To create a new color go to the `Assets` folder in your iOS app and create a new color set. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-create-new-color-set.png?raw=true) + + + +To create a color set, enter the RGB values or a hexcode as shown in the image below. If you don't see the sidebar with color options, click the icon in the top-right of your Xcode app and click the **Show attributes inspector** icon shown highlighted in blue. + + + +![](https://github.com/liveview-native/documentation_assets/blob/main/asset-catalogue-modify-my-color.png?raw=true) + + + +The defined color is now available for use within LiveView Native styles. However, the app needs to be re-compiled to pick up a new color set. + +Re-build your SwiftUI Application before moving on. Then evaluate the code below. You should see your custom colored text in the simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns), do: ~H"Hello" +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +## LiveView Native Stylesheets + +In LiveView Native, we use `~SHEET` sigil stylesheets to organize modifers by classes using an Elixir-oriented DSL similar to CSS for styling web elements. + +We group modifiers together within a class that can be applied to an element. Here's an example of how modifiers can be grouped into a "red-title" class in a stylesheet: + + + +```elixir +~SHEET""" + "red-title" do + foregroundColor(.red); + font(.title); + end +""" +``` + +We're mostly using Utility styles for these guides, but the stylesheet module does contain some important configuration to `@import` the utility styles module. It can also be used to group styles within a class if you have a set of modifiers you're repeatedly using and want to group together. + + + +```elixir +defmodule ServerWeb.Styles.App.SwiftUI do + use LiveViewNative.Stylesheet, :swiftui + @import LiveViewNative.SwiftUI.UtilityStyles + + ~SHEET""" + "red-title" do + foregroundColor(.red); + font(.title); + end + """ +end +``` + +You can apply these classes through the `class` attribute. + +```heex +Red Title Text +``` + +## Injecting Views in Stylesheets + +SwiftUI modifiers sometimes accept SwiftUI views as arguments. Here's an example using the `clipShape` modifier with a `Circle` view. + +```swift +Image("logo") + .clipShape(Circle()) +``` + +However, LiveView Native does not support using SwiftUI views directly within a stylesheet. Instead, we have a few alternative options in cases like this where we want to use a view within a modifier. + + + +### Using Members on a Given Type + +We can't use the [Circle](https://developer.apple.com/documentation/swiftui/circle) view directly. However, the [Getting standard shapes](https://developer.apple.com/documentation/swiftui/shape#getting-standard-shapes) documentation describes methods for accessing standard shapes. For example, we can use `Circle.circle` for the circle shape. + +We can use `Circle.circle` instead of the `Circle` view. So, the following code is equivalent to the example above. + +```swift +Image("logo") + .clipShape(Circle.circle) +``` + +However, in LiveView Native we only support using implicit member expression syntax, so instead of `Circle.circle`, we only write `.circle`. + +```swift +Image("logo") + .clipShape(.circle) +``` + +Which is simple to convert to the LiveView Native DSL using the rules we've already learned. + + + +```elixir +"example-class" do + clipShape(.circle) +end +``` + + + +### Injecting a View + +For more complex cases, we can inject a view directly into a stylesheet. + +Here's an example where this might be useful. SwiftUI has modifers that represent a named content area for views to be placed within. These views can even have their own modifiers, so it's not enough to use a simple static property on the [Shape](https://developer.apple.com/documentation/swiftui/shape) type. + +```swift +Image("logo") + .overlay(content: { + Circle().stroke(.red, lineWidth: 4) + }) +``` + +To get around this issue, we instead inject a view into the stylesheet. First, define the modifier and use an atom to represent the view that's going to be injected. + + + +```elixir +"overlay-circle" do + overlay(content: :circle) +end +``` + +Then use the `template` attribute on the view to be injected into the stylesheet. + +```heex + + + +``` + +## Apple Documentation + +You can find documentation and examples of modifiers on [Apple's SwiftUI documentation](https://developer.apple.com/documentation/swiftui) which is comprehensive and thorough, though it may feel unfamiliar at first for Elixir Developers when compared to HexDocs. + + + +### Finding Modifiers + +The [Configuring View Elements](https://developer.apple.com/documentation/swiftui/view#configuring-view-elements) section of apple documentation contains links to modifiers organized by category. In that documentation you'll find useful references such as [Style Modifiers](https://developer.apple.com/documentation/swiftui/view-style-modifiers), [Layout Modifiers](https://developer.apple.com/documentation/swiftui/view-layout), and [Input and Event Modifiers](https://developer.apple.com/documentation/swiftui/view-input-and-events). + +You can also find more on modifiers with LiveView Native examples on the [liveview-client-swiftui](https://hexdocs.pm/live_view_native_swiftui) HexDocs. + +## Visual Studio Code Extension + +If you use Visual Studio Code, we strongly recommend you install the [LiveView Native Visual Studio Code Extension](https://github.com/liveview-native/liveview-native-vscode) which provides autocompletion and type information thus making modifiers significantly easier to write and lookup. + +## Your Turn: Syntax Conversion + +Part of learning LiveView Native is learning SwiftUI. Fortunately we can leverage the existing SwiftUI ecosystem and convert examples into LiveView Native syntax. + +You're going to convert the following SwiftUI code into a LiveView Native template. This example is inspired by the official [SwiftUI Tutorials](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + +```elixir +VStack(alignment: .leading) { + Text("Turtle Rock") + .font(.title) + HStack { + Text("Joshua Tree National Park") + Spacer() + Text("California") + } + .font(.subheadline) + + Divider() + + Text("About Turtle Rock") + .font(.title2) + Text("Descriptive text goes here") +} +.padding() +``` + +
+Example Solution + +```elixir +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + + Turtle Rock + + Joshua Tree National Park + + California + + + About Turtle Rock + Descriptive text goes here + + """ + end +end +``` + +
+ +Enter your solution below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` diff --git a/livebooks/swiftui-views.livemd b/livebooks/swiftui-views.livemd new file mode 100644 index 000000000..57d7b720d --- /dev/null +++ b/livebooks/swiftui-views.livemd @@ -0,0 +1,173 @@ +# SwiftUI Views + +```elixir +notebook_path = __ENV__.file |> String.split("#") |> hd() + +Mix.install( + [ + {:kino_live_view_native, github: "liveview-native/kino_live_view_native"} + ], + config: [ + server: [ + {ServerWeb.Endpoint, + [ + server: true, + url: [host: "localhost"], + adapter: Phoenix.Endpoint.Cowboy2Adapter, + render_errors: [ + formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON], + layout: false + ], + pubsub_server: Server.PubSub, + live_view: [signing_salt: "JSgdVVL6"], + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + secret_key_base: String.duplicate("a", 64), + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$", + ~r/#{notebook_path}$/ + ] + ] + ]} + ], + kino: [ + group_leader: Process.group_leader() + ], + phoenix: [ + template_engines: [neex: LiveViewNative.Engine] + ], + phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]], + mime: [ + types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]} + ], + live_view_native: [plugins: [LiveViewNative.SwiftUI]], + live_view_native_stylesheet: [ + attribute_parsers: [ + style: [ + livemd: &Server.AttributeParsers.Style.parse/2 + ] + ], + content: [ + swiftui: [ + "lib/**/*swiftui*", + notebook_path + ] + ], + pretty: true, + output: "priv/static/assets" + ] + ], + force: true +) +``` + +## Overview + +LiveView Native aims to use minimal SwiftUI code and instead rely on the same patterns used in traditional Phoenix LiveView development as much as possible. We'll primarily use The LiveView Naive SwiftUI DSL (Domain Specific Language) to build the native template. + +This lesson will teach you how to build SwiftUI templates using common SwiftUI views. We'll cover common use cases and provide practical examples of how to build native UIs. This lesson is like a recipe book you can refer to whenever you need an example of a particular SwiftUI view. + +In addition, we'll cover the LiveView Native DSL and teach you how to convert SwiftUI examples into the LiveView Native DSL. Once you understand how to convert SwiftUI code into the LiveView Native DSL, you'll have the knowledge you need to learn from the plethora of [SwiftUI resources available](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views). + + + + + +```elixir +Hamlet +``` + +## Render Components + +LiveView Native `0.3.0` introduced render components to encourage isolation of native and web templates. This pattern generally scales better than co-located templates within the same LiveView module. + +Render components are namespaced under the main LiveView, and are responsible for defining the `render/1` callback function that returns the native template. + +For example, in the cell below, the `ExampleLive` LiveView module has a corresponding `ExampleLive.SwiftUI` render component module for the native template. This `ExampleLive.SwiftUI` render component may define a `render/1` callback function, as seen below. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +# Render Component +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns) do + ~LVN""" + Hello, from LiveView Native! + """ + end +end + +# LiveView +defmodule ServerWeb.ExampleLive do + use ServerWeb, :live_view + use ServerNative, :live_view + + @impl true + def render(assigns) do + ~H""" +

Hello from LiveView!

+ """ + end +end +|> Server.SmartCells.LiveViewNative.register("/") + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +Throughout this and further material, we'll re-define render components you can evaluate and see reflected in your Xcode iOS simulator. + + + +```elixir +require Server.Livebook +import Server.Livebook +import Kernel, except: [defmodule: 2] + +defmodule ServerWeb.ExampleLive.SwiftUI do + use ServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + Hello, from a LiveView Native Render Component! + """ + end +end +|> Server.SmartCells.RenderComponent.register() + +import Server.Livebook, only: [] +import Kernel +:ok +``` + +In a Phoenix application, these two modules would traditionally be in separate files. + + + +### Embedding Templates + +Instead of defining a `render/1` callback function, you may instead define a `.neex` (Native + Embedded Elixir) template. + +By default, the module above would look for a template in the `swiftui/example_live*` path relative to the module's location. You can see the `LiveViewNative.Component` documentation for further explanation. + +In Livebook, we'll use the `render/1` callback. However, we recommend using template files for local Phoenix + LiveView Native applications. + +## SwiftUI Views + +In SwiftUI, a "View" is like a building block for what you see on your app's screen. It can be something simple like text, or something more complex like a layout with multiple elements. + +Here's an example `Text` view that represents a text element. + +```swift +Text("Hamlet") +``` + +LiveView Native uses the following syntax to represent the view above. diff --git a/mix.exs b/mix.exs index 8ac56c51c..316ad2b1c 100644 --- a/mix.exs +++ b/mix.exs @@ -1,6 +1,5 @@ defmodule LiveViewNative.SwiftUI.MixProject do use Mix.Project - @version "0.3.0-rc.1" @source_url "https://github.com/liveview-native/liveview-client-swiftui" @@ -33,7 +32,7 @@ defmodule LiveViewNative.SwiftUI.MixProject do defp aliases do [ - docs: ["lvn.swiftui.gen.docs", "docs"] + docs: &various_docs/1 ] end @@ -42,7 +41,7 @@ defmodule LiveViewNative.SwiftUI.MixProject do defp elixirc_paths(_), do: ignore_docs_task(["lib"]) defp ignore_docs_task(paths) do - Enum.flat_map(paths, fn(path) -> + Enum.flat_map(paths, fn path -> Path.wildcard("#{path}/**/*.ex") end) |> Enum.filter(&(!(&1 =~ "lvn.swiftui.gen.docs"))) @@ -58,32 +57,22 @@ defmodule LiveViewNative.SwiftUI.MixProject do # {:live_view_native, "~> 0.3.0-rc.1"}, {:live_view_native, github: "liveview-native/live_view_native", override: true}, {:live_view_native_stylesheet, "~> 0.3.0-rc.1", only: :test}, - {:live_view_native_test, github: "liveview-native/live_view_native_test", branch: "main", only: :test}, + {:live_view_native_test, + github: "liveview-native/live_view_native_test", tag: "v0.3.0", only: :test}, {:nimble_parsec, "~> 1.3"} ] end defp docs do - guides = Path.wildcard("guides/**/*.md") - generated_docs = Path.wildcard("generated_docs/**/*.{md,cheatmd}") - - extras = ["README.md"] ++ guides ++ generated_docs - - guide_groups = [ - "Architecture": Path.wildcard("guides/architecture/*.md") - ] - - generated_groups = - Path.wildcard("generated_docs/*") - |> Enum.map(&({Path.basename(&1) |> String.to_atom(), Path.wildcard("#{&1}/*.md")})) - [ - extras: extras, - groups_for_extras: guide_groups ++ generated_groups, + extras: extras(), + groups_for_extras: groups_for_extras(), groups_for_functions: [ Components: &(&1[:type] == :component), Macros: &(&1[:type] == :macro) ], + extras: extras(), + groups_for_extras: groups_for_extras(), main: "readme", source_url: @source_url, source_ref: "v#{@version}", @@ -111,6 +100,10 @@ defmodule LiveViewNative.SwiftUI.MixProject do } }); + """ } ] @@ -118,6 +111,38 @@ defmodule LiveViewNative.SwiftUI.MixProject do defp description, do: "LiveView Native SwiftUI Client" + defp extras do + guides = Path.wildcard("guides/**/*.md") + generated_docs = Path.wildcard("generated_docs/**/*.{md,cheatmd}") + + livebooks = + [ + "livebooks/markdown/getting-started.md", + "livebooks/markdown/create-a-swiftui-application.md", + "livebooks/markdown/swiftui-views.md", + "livebooks/markdown/interactive-swiftui-views.md", + "livebooks/markdown/stylesheets.md", + "livebooks/markdown/native-navigation.md", + "livebooks/markdown/forms-and-validation.md" + ] + + ["README.md", "guides/syntax_conversion.cheatmd"] ++ guides ++ generated_docs ++ livebooks + end + + defp groups_for_extras do + guide_groups = [ + Architecture: Path.wildcard("guides/architecture/*.md"), + Livebooks: Path.wildcard("livebooks/markdown/*.md"), + Cookbooks: Path.wildcard("livebooks/markdown/*cookbook*.livemd") + ] + + generated_groups = + Path.wildcard("generated_docs/*") + |> Enum.map(&{Path.basename(&1) |> String.to_atom(), Path.wildcard("#{&1}/*.md")}) + + guide_groups ++ generated_groups + end + defp package do %{ maintainers: ["Brian Cardarella"], @@ -129,4 +154,15 @@ defmodule LiveViewNative.SwiftUI.MixProject do } } end + + defp various_docs(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [skip_gen_docs: :boolean, skip_livebooks: :boolean] + ) + + unless opts[:skip_gen_docs], do: Mix.Task.run("lvn.swiftui.gen.docs") + unless opts[:skip_livebooks], do: Mix.Task.run("lvn.swiftui.gen.livemarkdown") + Mix.Task.run("docs") + end end diff --git a/mix.lock b/mix.lock index e018f17ab..030dc02cb 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "live_view_native": {:git, "https://github.com/liveview-native/live_view_native.git", "65fc88252a21342116ed4ce4791e09c49d9d4c32", []}, "live_view_native_stylesheet": {:hex, :live_view_native_stylesheet, "0.3.0-rc.1", "6675fca5fbaf23805a6a7b4214c0600ad4f996f31a9dccb48fa765b3e2e93455", [:mix], [{:live_view_native, "~> 0.3.0-rc.1", [hex: :live_view_native, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "08e6f51b6708340f49d39e64f782dbfc12c73c889f234822cbbab4868e84862f"}, - "live_view_native_test": {:git, "https://github.com/liveview-native/live_view_native_test.git", "539ae931fa3936f3ee2f73ffa11f7100fe6554db", [branch: "main"]}, + "live_view_native_test": {:git, "https://github.com/liveview-native/live_view_native_test.git", "f36efa463e172df27d50ab0bcbd16f2e59e6c05b", [tag: "v0.3.0"]}, "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_eex": {:hex, :makeup_eex, "0.1.2", "93a5ef3d28ed753215dba2d59cb40408b37cccb4a8205e53ef9b5319a992b700", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "6140eafb28215ad7182282fd21d9aa6dcffbfbe0eb876283bc6b768a6c57b0c3"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, diff --git a/priv/bin/xcodegen b/priv/bin/xcodegen new file mode 100755 index 000000000..4c9ad88df Binary files /dev/null and b/priv/bin/xcodegen differ diff --git a/priv/templates/lvn.swiftui.gen/core_components.ex b/priv/templates/lvn.swiftui.gen/core_components.ex index fd4c6bff7..faae29593 100644 --- a/priv/templates/lvn.swiftui.gen/core_components.ex +++ b/priv/templates/lvn.swiftui.gen/core_components.ex @@ -44,7 +44,7 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m ## Examples - + <.input field={@form[:email]} type="TextField" /> <.input name="my-input" errors={["oh no!"]} /> @@ -95,10 +95,10 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m |> assign_new(:value, fn -> field.value end) |> assign( :rest, - Map.put(assigns.rest, :class, [ - Map.get(assigns.rest, :class, ""), - (if assigns.readonly or Map.get(assigns.rest, :disabled, false), do: "disabled-true", else: ""), - (if assigns.autocomplete == "off", do: "text-input-autocapitalization-never autocorrection-disabled", else: "") + Map.put(assigns.rest, :style, [ + Map.get(assigns.rest, :style, ""), + (if assigns.readonly or Map.get(assigns.rest, :disabled, false), do: "disabled(true)", else: ""), + (if assigns.autocomplete == "off", do: "textInputAutocapitalization(.never) autocorrectionDisabled()", else: "") ] |> Enum.join(" ")) ) |> input() @@ -238,7 +238,7 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m def error(assigns) do ~LVN""" - + <%%= render_slot(@inner_block) %> """ @@ -251,18 +251,17 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m """ @doc type: :component - attr :class, :string, default: nil - slot :inner_block, required: true slot :subtitle slot :actions def header(assigns) do ~LVN""" - + <%%= render_slot(@inner_block) %> @@ -295,7 +294,7 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m """ attr :id, :string, required: true attr :show, :boolean, default: false - attr :on_cancel, :string + attr :on_cancel, :string, default: nil slot :inner_block, required: true def modal(assigns) do @@ -303,7 +302,7 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m @@ -337,7 +336,10 @@ defmodule <%= inspect context.web_module %>.CoreComponents.<%= inspect context.m <%% msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind) %>