Skip to content

Support ButtonStyle and PrimitiveButtonStyle protocols - #523

Open
gurmeherchawla wants to merge 13 commits into
skiptools:mainfrom
Clockworks-Solutions:feature/button-style-protocol
Open

gurmeherchawla wants to merge 13 commits into
skiptools:mainfrom
Clockworks-Solutions:feature/button-style-protocol

Conversation

@gurmeherchawla

Copy link
Copy Markdown

Replaces the raw-value ButtonStyle bridging struct with proper ButtonStyle and PrimitiveButtonStyle protocols matching SwiftUI's API, enabling custom button styles via makeBody on Android.

Labels: enhancement

What changed

ButtonStyle / PrimitiveButtonStyle protocols
The old ButtonStyle struct was a raw-value enum used purely for bridging. It is replaced by two protocols:

  • PrimitiveButtonStyle — for styles that control their own interaction (matches SwiftUI)
  • ButtonStyle — for styles that receive isPressed state and defer interaction to the framework

All built-in styles (DefaultButtonStyle, PlainButtonStyle, BorderedButtonStyle, BorderedProminentButtonStyle, BorderlessButtonStyle, M3TextButtonStyle) are now concrete structs conforming to PrimitiveButtonStyle. They continue to render natively in Compose without going through makeBody.

StackedButtonStyle
Introduced to track the active style chain in the environment. When Button(configuration) is called inside a custom makeBody, it renders in the next outer style rather than recursing — matching SwiftUI's behaviour. The source reference prevents a modifier from making itself its own parent when it re-evaluates within its own scope.

RenderCustomStyleButton
New rendering path for non-built-in styles. Calls makeBody, sets _buttonStyle to the parent stack node, then composes the body so that any nested Button(configuration) delegates outward correctly.

Bridge support
BridgedButtonStyle and BridgedPrimitiveButtonStyle allow natively-compiled styles (from skip-fuse-ui) to cross the bridge. ButtonStyleBridgedConfiguration exposes environmentSupport(forKey:) so the bridge layer can sync @Environment values declared on the style at each button's Compose position. A new buttonStyle(isPrimitive:environmentKeys:bridgedMakeBody:) overload is the bridge entry point.

Other fixes

  • ToggleStyle static properties (automatic, button, switch) were incorrectly typed as ButtonStyle — fixed.
  • Picker and NavigationLink list-item rendering updated to use the new isListItemStyle helper.

Related PR

skip-fuse-ui: skiptools/skip-fuse-ui#143

automatic, button, and switch were accidentally typed as ButtonStyle instead of ToggleStyle.
…uration types

Introduces PrimitiveButtonStyle protocol and its concrete implementations: DefaultButtonStyle, PlainButtonStyle, BorderlessButtonStyle, BorderedButtonStyle, BorderedProminentButtonStyle, and M3TextButtonStyle. Adds functional ButtonStyleConfiguration and PrimitiveButtonStyleConfiguration replacing the dead stubs in comments, and Button(configuration:) init so styles can delegate rendering via makeBody. Coexists with the existing ButtonStyle bridging struct unchanged.
Tracks the active button style chain in the environment. Each node holds the current style, its parent (the style it overrides), and the modifier that installed it. isBuiltin distinguishes built-in styles that render natively in Compose from custom styles that render via makeBody.
…p StackedButtonStyle

Replaces the raw-value ButtonStyle struct with a ButtonStyle protocol matching SwiftUI's API. Updates ButtonStyleModifier to build the StackedButtonStyle chain and EnvironmentValues._buttonStyle to store it. Updates RenderButton to dispatch on the unwrapped style using type checks (is BorderedButtonStyle etc.), and updates shouldRenderListItem/RenderListItem to use the new isListItemStyle helper. Updates Navigation and Picker call sites.
Adds RenderCustomStyleButton which invokes makeBody on custom styles and composes the body with _buttonStyle set to the parent, enabling Button(configuration) delegation. Adds BridgedButtonStyle and BridgedPrimitiveButtonStyle for natively-compiled styles that cross the Skip bridge, with per-button @Environment syncing via ButtonStyleBridgedConfiguration. Adds buttonStyle(isPrimitive:environmentKeys:bridgedMakeBody:) for the bridge entry point.
@gurmeherchawla

Copy link
Copy Markdown
Author

closes #263

@gurmeherchawla

Copy link
Copy Markdown
Author

@marcprux

@piercifani piercifani left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Keeping the parent style in StackedButtonStyle is a clean way to get the SwiftUI behavior where Button(configuration) renders in the enclosing style. I tested this together with skip-fuse-ui#143 in a real Fuse app on Android. Existing .plain buttons render as before, and custom ButtonStyle bodies render and respond to taps. One change before merge:

1. Add Lite tests for the custom style paths

Nothing in skip-ui exercises RenderCustomStyleButton or the StackedButtonStyle hand-off. In Tests/SkipUITests/SkipUITests.swift, add this next to testButton:

func testCustomButtonStyles() throws {
    try testUI(view: {
        CustomButtonStyleTestView().accessibilityIdentifier("test-view")
    }, eval: { rule in
        try check(rule, id: "label", hasText: "Counter: 0")
        #if SKIP
        // ButtonStyle: makeBody renders around the label and the tap fires the action
        rule.onNodeWithTag("styled.body").assertIsDisplayed()
        rule.onNodeWithTag("styled").performClick()
        try check(rule, id: "label", hasText: "Counter: 1")

        // PrimitiveButtonStyle whose Button(configuration) hands off to the outer .bordered style without recursing
        rule.onNodeWithTag("primitive").performClick()
        try check(rule, id: "label", hasText: "Counter: 11")

        // Disabled custom-styled button doesn't fire
        rule.onNodeWithTag("disabled").performClick()
        try check(rule, id: "label", hasText: "Counter: 11")
        #endif
    })
}
struct CustomButtonStyleTestView: View {
    @State var counter = 0
    var body: some View {
        VStack {
            Text("Counter: \(counter)")
                .accessibilityIdentifier("label")
            Button("Styled") { counter += 1 }
                .buttonStyle(TestButtonStyle())
                .accessibilityIdentifier("styled")
            VStack {
                Button("Primitive") { counter += 10 }
                    .buttonStyle(TestPrimitiveButtonStyle())
                    .accessibilityIdentifier("primitive")
            }
            .buttonStyle(.bordered)
            Button("Disabled") { counter += 100 }
                .buttonStyle(TestButtonStyle())
                .disabled(true)
                .accessibilityIdentifier("disabled")
        }
    }
}
struct TestButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.label
            Text(configuration.isPressed ? "pressed" : "idle")
                .accessibilityIdentifier("styled.body")
        }
        .padding()
    }
}
struct TestPrimitiveButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button(configuration)
            .padding()
    }
}

The primitive case is the important one. If the source guard in ButtonStyleModifier ever regresses, Button(configuration) will render back into the same style and recurse, and this test will hang or overflow the stack instead of passing silently.

Related, but not for this PR

Once styles read isEnabled, one existing SkipUI bug gets more visible. DisabledModifier (in View/AdditionalViewModifiers.swift) sets isEnabled = !disabled instead of combining it with the outer value. An inner .disabled(false), common inside reusable button wrappers, re-enables a subtree that an outer .disabled(true) disabled. SwiftUI combines the two. The fix is one line, which I verified on device:

-            $0.setisEnabled(!disabled)
+            $0.setisEnabled($0.isEnabled && !disabled)

I'll open that as a separate PR so it doesn't hold this one up.

Dhruv-Clockworks and others added 8 commits October 15, 2026 17:16
This fixes a bug where, after the first withAnimation, every later change kept animating on Android
Adds support for ScrollPosition, onScrollGeometryChange and onScrollPhaseChange.
Adds ScrollPosition, scroll geometry, and scroll phase support
Adds document for scroll position, geometry and phase support
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants