Skip to content

[FEAT] LinearGradient, RadialGradient & AngularGradient#450

Open
MiaKoring wants to merge 28 commits intomoreSwift:mainfrom
MiaKoring:feat/gradient
Open

[FEAT] LinearGradient, RadialGradient & AngularGradient#450
MiaKoring wants to merge 28 commits intomoreSwift:mainfrom
MiaKoring:feat/gradient

Conversation

@MiaKoring
Copy link
Copy Markdown
Collaborator

@MiaKoring MiaKoring commented Feb 17, 2026

Summary

Added support for 3 types of gradients to be rendered as Views:

  • LinearGradient
  • RadialGradient
  • AngularGradient

Changes

SwiftCrossUI

  • added functions for creation and update of each gradient type to the AppBackend protocol.
  • added an ElementaryViewstruct for each type of gradient.
  • added a struct Angle intended for use by AngularGradient later
    • gets also used by multiple backends for calculations
    • added an initializer to create an Angle from a UnitPoint
  • added a struct UnitPoint, meant to represent a normalized point in a view’s coordinate space
    • added static values for various points on a rectangle
    • AppKitBackend views using it may need to flip the coordinates vertically, due to AppKit using a different coordinate origin point than the other major platforms.
    • UnitPoints are not clamped, so you can use them with arbitrary values to benefit for example from Angle(origin:destination:)
  • added a struct Gradient, used by all implementations of a Gradient, its a gradient type agnostic representation of a color gradient
    • added nested struct Gradient.Stop representing a color and its normalized position in the gradient
    • added initializers taking [Color] or [Gradient.Stop]

AppKitBackend, UIKitBackend

  • Added implementations of the new AppBackend methods
  • Added separate Widget classes when needed (AppKit & UIKit)

WinUI, GtkBackend

  • Added implementation of the new AppBackend methods related to LinearGradient and RadialGradient

SwiftCrossUITests

  • Added Tests validating the initializers of Gradient
    • even distribution of stops when supplied [Color]
    • transparent stops when passed an empty array of Color (to make future support of addition possible and remove edgecases to check outside of Gradient)
    • start and end stop of the same color when passed [Color] with one entry
    • preservation of the order the colors are passed

Examples

  • Added GradientsExample
    • added tab with examples for LinearGradient
    • added tab with examples for RadialGradient
    • added tab with examples for AngularGradient

Notes

  • WinUI 2 and 3 don’t support conic (angular) gradients yet. I currently don’t know what an alternative could be
  • This PR only adds support for rendering them as views directly. A future ShapeStyle could easily re-use the added structs tho.

TODO / Status

  • Added RadialGradient support to WinUIBackend (pending RadialGradientBrush generation)
  • “unavailable on WinUIBackend” documentation comment is removed from RadialGradient

@MiaKoring
Copy link
Copy Markdown
Collaborator Author

MiaKoring commented Feb 17, 2026

Screenshot 2026-02-17 at 20 29 46 Screenshot 2026-02-17 at 20 29 57 Screenshot 2026-02-18 at 12 38 59 Screenshot 2026-02-17 at 20 30 46 Screenshot 2026-02-17 at 20 30 54 Screenshot 2026-02-18 at 12 39 07

@MiaKoring
Copy link
Copy Markdown
Collaborator Author

Related issue: #427

@MiaKoring
Copy link
Copy Markdown
Collaborator Author

The new commit now offers full API parity to SwiftUI on AngularGradient and correctly renders all 6 test gradients. “Correct” meaning looking like the SwiftUI rendered result.

All 3 supported frameworks recieved this change and the shared screenshots were updated.

@MiaKoring MiaKoring marked this pull request as ready for review February 18, 2026 15:53
@MiaKoring
Copy link
Copy Markdown
Collaborator Author

Outstanding Tasks have been completed, this can now be reviewed and merged

Copy link
Copy Markdown
Collaborator

@stackotter stackotter left a comment

Choose a reason for hiding this comment

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

Thanks for tackling this feature! I've reviewed all of the code now, but I haven't tested anything locally yet. I plan to construct a SwiftUI app with a bunch of edge cases that I have thought of, and then compile that with your PR and ensure that all of the edge cases act the same across platforms. It should be a useful tool to help you address some of my PR comments too. I likely won't get around to doing that today though.

Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Sources/GtkBackend/GtkBackend+Gradient.swift Outdated
Comment thread Sources/GtkBackend/GtkBackend+Gradient.swift Outdated
Comment thread Sources/AppKitBackend/AppKitBackend+Gradient.swift Outdated
Comment thread Sources/UIKitBackend/UIKitBackend+Gradient.swift Outdated
Comment thread Sources/UIKitBackend/UIKitBackend+Gradient.swift
@MiaKoring
Copy link
Copy Markdown
Collaborator Author

conflicts should be resolved now, same for all requested changes without open discussion

Copy link
Copy Markdown
Collaborator

@stackotter stackotter left a comment

Choose a reason for hiding this comment

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

Thanks for applying all that feedback! I've got a few more things, but I think most of them are relatively small things this time (organisation, documentation, etc). I don't have time to test things locally right now, but I will try to do that soon, because I may be able to resolve some of my own questions and comments by testing out the edge cases that I'm worried about.

Comment thread Examples/Sources/GradientsExample/GradientsApp.swift
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Sources/GradientsExample/GradientsApp.swift Outdated
Comment thread Examples/Package.swift Outdated
Comment thread Sources/SwiftCrossUI/Views/Gradients/RadialGradient.swift Outdated

#if DEBUG
if range < 0 {
logger.warning(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it's easy enough for us to support right? Because we can just automatically reverse the gradient. Use cases where it's probably most applicable would be graphical editors where the user is entering the radii with draggable handles or something similar. I assume some backends that don't use adjustedStops may already support having the radii swapped?

Comment thread Sources/SwiftCrossUI/Views/Gradients/RadialGradient.swift Outdated
Comment thread Sources/AppKitBackend/AppKitBackend+Gradient.swift
Comment thread Sources/GtkBackend/GtkBackend+Gradient.swift Outdated
@MiaKoring
Copy link
Copy Markdown
Collaborator Author

GtkBackend will not support the AngularGradient type right now due to rendering inconsistencies with SwiftUI and the Apple Backends which are not easily resolvable.

For future reference if needed:

public func createAngularGradient() -> Widget {
    Box()
}

public func updateAngularGradient(
    _ widget: Widget,
    gradient: AngularGradient,
    withSize size: SIMD2<Int>,
    in environment: EnvironmentValues
) {
    let widget = widget as! Box

    let adjustedStops = gradient.adjustedStops

    let stops = adjustedStops.map { stop in
        let resolved = stop.color.resolve(in: environment)
        let red = resolved.red * 255
        let green = resolved.green * 255
        let blue = resolved.blue * 255
        
        let location = stop.location * 360
        
        return "rgba(\(red), \(green), \(blue), \(resolved.opacity)) \(location)deg”
    }.joined(separator: ",)
    
    let startDegrees = gradient.startAngle.degrees + 90
    let centerXPercent = gradient.center.x * 100
    let centerYPercent = gradient.center.y * 100

    widget.css.set(
        property: .init(
            key: “background”,
            value: “””
                conic-gradient(from \(startDegrees)deg \
                at \(centerXPercent)% \(centerYPercent)%, \
                \(stops))
                “””
        )
    )
}

@MiaKoring
Copy link
Copy Markdown
Collaborator Author

I believe I’m caught up. main is merged again too.

@MiaKoring MiaKoring requested a review from stackotter May 6, 2026 18:16
Copy link
Copy Markdown
Collaborator

@stackotter stackotter left a comment

Choose a reason for hiding this comment

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

Thanks for resolving those comments, the implementation looks great now. My feedback this time is purely formatting and documentation wording, so this should be the home straight.

I believe the @bbrk24 was having issues with Xcode adding additional trailing whitespace indents, so maybe some default setting has changed recently?

struct GradientsApp: App {
static let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This file has ended up with a lot of trailing whitespace added. Maybe Xcode did it?


#if canImport(SwiftBundlerRuntime)
import SwiftBundlerRuntime
import SwiftBundlerRuntime
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Indent one level

/// - widget: The widget to update.
/// - gradient: The SwiftCrossUI struct housing the information for the gradient's rendering.
/// - size: The new size of the widget.
/// - environment: The widgets environment, used to resolve its colors.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

widget's

/// - widget: The widget to update.
/// - gradient: The SwiftCrossUI struct housing the information for the gradient's rendering.
/// - size: The new size of the widget.
/// - environment: The widgets environment, used to resolve its colors.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

widget's

@@ -0,0 +1,22 @@
extension BackendFeatures {
/// Backend Methods for Angular (conic) Gradients
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Don't need to capitalise the Methods here, or the Angular and Gradients either

/// Creates an angular gradient that completes a partial rotation.
///
/// Stops are expected to be in 360° unit space.
/// For ``Gradient.Stop`` location of 0 corresponds to 0° and 1 to 360°.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

/// For each ``Gradient.Stop``, a location of 0 corresponds to 0°, and a location of 1 corresponds to 360°.

/// The radius at which the first gradient stop will be placed.
///
/// All space outside inside radius gets filled with the color of the first gradient stop.
/// All space inside radius gets filled with the color of the first gradient stop.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

inside this radius

/// The normalized center point of the gradient in its coordinate space.
public let center: UnitPoint

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Trailing whitespace

}

public func updateLinearGradient(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Trailing whitespace

let widget = widget as! Box

let stops = cssStops(gradient: gradient.gradient, environment: environment)
let stops = gradient.startRadius < gradient.endRadius ?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wrap the ternary like this;

let stops = gradient.startRadius < gradient.endRadius
  ? gradient.gradient.stops
  : invertedStops(stops: gradient.gradient.stops)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants