LiveViewNative SwiftUI

repository·main·Indexed 19 days ago

https://github.com/liveview-native/liveview-client-swiftui

A client-side implementation for building native iOS, iPadOS, and macOS apps using Phoenix LiveView. It enables server-side rendering of SwiftUI components via a specialized protocol, utilizing a hierarchical coordination model consisting of LiveSessionCoordinator, LiveViewCoordinator, and NavStackEntryView to map server state to native views.

Tokens
11.3K
Snippets
41
Records
58
Agent score
66%

What's inside liveview-client-swiftui

  1. How update isolation works via NodeRefs

    main

    To maintain high performance, the client avoids re-rendering the entire view tree on every update. Instead, it uses Update Isolation.

    LiveViewCoordinator maintains a dictionary mapping a NodeRef to a Combine publisher. When core identifies a change, it sends an update event for a specific NodeRef. This event is broadcast through the corresponding publisher, notifying only the specific SwiftUI views subscribed to that node. This ensures that a change in one part of the UI does not trigger unnecessary re-renders in unrelated parts of the tree.

  2. How LiveViewNative components work together

    main

    LiveViewNative uses a hierarchical coordination model to map Phoenix LiveView server-side state to SwiftUI views. The architecture follows this flow:

    1. LiveView: The entry point SwiftUI View that initiates the connection.
    2. LiveSessionCoordinator: Manages the single WebSocket connection for the entire LiveView session.
    3. LiveViewCoordinator: Manages individual Phoenix Channels. As the server performs navigation (via push_event, push_patch, or redirect), the LiveSessionCoordinator spawns or manages multiple LiveViewCoordinator instances, each representing a route in the navigation stack.
    4. NavStackEntryView: The actual SwiftUI view hierarchy that renders the content received for a specific route.

    This design allows for complex layouts, such as a sidebar/content/detail split, where different parts of the screen can render different Phoenix LiveViews simultaneously over the same shared WebSocket connection.

  3. Use ContainerRelativeShape to match container shapes

    main

    ContainerRelativeShape is a shape that fills an inset version of the container's shape. This is useful for ensuring that child elements or backgrounds respect the clipping or corner radius of their parent container.

    To define which shape the ContainerRelativeShape should follow, use the .containerShapeModifier() modifier. If no specific container shape is provided via this modifier, the default behavior is to use a Rectangle.

    // Example usage concept
    // Use .containerShapeModifier() to provide the shape that ContainerRelativeShape will follow
  4. Understand navigation styles: push vs replace

    main

    Navigation in the SwiftUI client follows two styles that determine how the navigation stack and history behave:

    • push: Appends a new entry to the history. In SwiftUI, this triggers a system page push animation and provides a back button to return to the previous page.
    • replace: Replaces the current top-most entry in the navigationPath. In SwiftUI, this means no back button is available to return to the previous page.
  5. How SwiftUI modifiers are represented in LiveView Native

    main

    In SwiftUI, modifiers are functions defined on the View protocol (e.g., .foregroundStyle()). In LiveView Native, these are represented as structs that conform to the ViewModifier protocol.

    Each modifier struct uses the @ParseableExpression macro, which automatically generates a parser based on the struct's init definition. This allows the LiveView server to send modifier instructions that the SwiftUI client can parse and apply to the view hierarchy.

    // Example of how a LiveView Native modifier struct is structured
    @ParseableExpression
    struct _boldModifier: ViewModifier {
        static let name = "bold"
    
        let isActive: Bool
    
        init(_ isActive: Bool) {
            self.isActive = isActive
        }
    
        func body(content: Content) -> some View {
            content.bold(isActive)
        }
    }
  6. Use StylesheetResolvable for dynamic SwiftUI primitives

    main

    The StylesheetResolvable protocol allows you to decode SwiftUI primitives (like HorizontalAlignment or ShapeStyle) that need to be resolved from a stylesheet.

    Many SwiftUI types have a nested .Resolvable type. You can use these in your modifiers to allow dynamic values in stylesheets.

    Examples:

    1. Using built-in SwiftUI types (e.g., HorizontalAlignment):

    struct MyModifier<Root: RootRegistry>: ViewModifier, Decodable {
        let alignment: HorizontalAlignment.Resolvable
    
        func body(content: Content) -> some View {
            VStack(alignment: alignment.resolve(on: element, in: context)) { content }
        }
    }

    Stylesheet: myModifier(alignment: .trailing)

    2. Using specialized protocols (e.g., ShapeStyle): Use StylesheetResolvableShapeStyle to decode a type-erased ShapeStyle.

    struct FillBackgroundModifier<Root: RootRegistry>: ViewModifier, @preconcurrency Decodable {
        let fill: StylesheetResolvableShapeStyle
    
        init(_ fill: StylesheetResolvableShapeStyle) {
            self.fill = fill
        }
        
        func body(content: Content) -> some View {
            content.background(fill)
        }
    }

    Stylesheet: fillBackground(.red.opacity(attr("opacity")))

    3. Creating custom resolvable types: Conform your own struct to StylesheetResolvable to allow its properties to use attr(<name>).

    struct Video {
        let url: String
        let resolution: Int
    
        struct Resolvable: StylesheetResolvable, Decodable {
            let url: AttributeReference<String>
            let resolution: AttributeReference<Int>
    
            func resolve(on element: ElementNode, in context: LiveContext<some RootRegistry>) -> Video {
                Video(
                    url: url.resolve(on: element, in: context),
                    resolution: resolution.resolve(on: element, in: context)
                )
            }
        }
    }

    Stylesheet: backgroundVideo(Video("...", in: attr("resolution")))

    @ASTDecodable("fillBackground")
    struct FillBackgroundModifier<Root: RootRegistry>: ViewModifier, @preconcurrency Decodable {
        let fill: StylesheetResolvableShapeStyle
    
        init(_ fill: StylesheetResolvableShapeStyle) {
            self.fill = fill
        }
        
        func body(content: Content) -> some View {
            content.background(fill)
        }
    }
  7. How navigation is handled in LiveView Native SwiftUI

    main

    Navigation is managed via a navigationPath within the LiveSessionCoordinator. The process follows this flow:

    1. The LiveViewCoordinator receives navigation events from the Phoenix LiveView over the channel.
    2. The LiveViewCoordinator forwards these requests to the LiveSessionCoordinator.
    3. The LiveSessionCoordinator updates the navigationPath to reflect the new state.
  8. Understand the difference between Attributes and Modifiers

    main

    In LiveViewNative, you apply changes to elements using either Attributes or Modifiers. Choosing the right one depends on the scope of the change you want to apply.

    Attributes

    • Scope: Apply to a single, specific element.
    • Implementation: Implemented directly on specific view types (e.g., placeholder on <doc:TextField>).
    • Data Format: Stores a single string value.
    • Use Case: Use attributes when a property is unique to a specific element type.

    Modifiers

    • Scope: Apply to a large swath of elements or broad categories of elements.
    • Implementation: Implemented separately from view types; they cannot directly access or manipulate specific views.
    • Data Format: Stored as JSON objects within a JSON array on the modifiers attribute of an element. This array structure allows for order-dependent composition.
    • Capabilities: Can hold multiple related values (e.g., distinct values for different edges of a padding modifier).
    • Use Case: Use modifiers when you want to reuse changes across many elements or apply a consistent style to a category of views.
  9. Understand the role of LiveViewCoordinator

    main

    A LiveViewCoordinator manages a single Phoenix Channel. While the LiveSessionCoordinator handles the socket, the LiveViewCoordinator handles the specific communication for a route.

    When Phoenix LiveView triggers navigation using push_event, push_patch, or redirect, the client updates its internal navigation stack. Each entry in this stack is managed by its own LiveViewCoordinator. This allows multiple channels to operate over the same shared socket, enabling split-view layouts (like sidebar/content/detail) where each section renders a different LiveView.