SwiftUI Gradient Builder
In the last post about gradient shader I covered GPU part of gradient rendering in Metal but if you’ll use it in your apps it won’t make enough sense without giving user the ability to build a gradient in UI. I’m a big fan of SwiftUI and in my opinion current state of this new UI framework including iOS16 API gives all the necessary tools to build apps of any kinds and complexity. Today I’ll show you how to create this fancy control to construct gradients purely on SwiftUI.
TL; DR
I know that a lot of you folks prefer Swift over English ;) I’m also this kind of guy so in order not to torture you with a lot of text here is a repo with all the code from this post and you can go through the implementation by yourself. The repo also contains the CPU part of gradient rendering which I skipped in previous post.
Decomposing Control
For our gradient control there will be four separate views packed in a single root view.
GradientView
will contain only the linear gradient with some UI modifications;StopsView
will haveForEach
loop with all the stops placed on top ofGradientView
;StopView
is a host view for a single stop;SelectedStopView
is the bottom view for current stop color description and modification.
Why view decomposition in SwiftUI matters? It is not only about readability and reusability of your code. SwiftUI view lifecycle has two main stages. Like any Swift struct
it has init
which will be called each time you create a view in other view’s or scene’s body
and it has this body
which will be fired each time view’s state changes. So your main goal as a developer is to limit unnecessary body
calls when view state doesn’t actually changed but SwiftUI thinks it does. How it can happen?
Actually any change in @State
or @Binding
in view or @Published
variable of ObservableObject
will require body
call on next frame. That means that if you have a huge view described in a single view’s body
in all this fine declarative way the whole body
will be called each time you change the title of one tiny button. So proper decomposition not only allows you to maintain code easily but makes it faster!
The last thing before diving into views’ implementation we need to define the data model.
Gradient Stop Model
We won’t use SwiftUI Gradient.Stop
as data model for two reasons. First since this control is made for gradient shader from previous post we need color to be defined as SIMD4<Float>
but not SwiftUI Color
. Second we need to support selection of particular stop to allow user to change the color. And to preserve selection while changing color or location we need our model to conform to Identifiable
protocol and has some static unique ID.
⚠️ The full Gradient model contains gradient type and rotation angle but I omit them in this post. Also we need to implement conversion between SIMD color and SwiftUI color which I also won’t show here to keep focus on UI related code. You can find the full implementation in repo.
GradientView
This view is pretty straightforward. It just renders linear gradient inside RoundedRectangle
with semitransparent border overlay. And on tap gesture the new stop will be added to stops array between last two elements. It is the only problem with current implementation since TapGesture
in SwiftUI doesn’t provide the location of tap. In iOS16 there is a new SpatialTapGesture
added which provides the location but I wanted to keep iOS15 as deployment target so that you’ll be able to run the code from repo on your current devices.
StopsView
Above gradient view there is a container view for all the stops. It is also rather easy to implement. Just ForEach
loop on all the stops. But since our model define stop location in normalized coordinates between 0 and 1 we need to provide the parent view width to this view to place stops in proper locations. Also this view receives binding to selected stop ID and proxies it to all the StopView
s to support selection.
This is another reason why we need to conform GradientStop
to Identifiable
protocol. ForEach
supports iteration on array binding and inside its content stop
will also be a binding. But to be able to call this initializer your data for iteration needs to be Identifiable
.
StopView
This view is just a circle with current stop color, border and shadow. But it supports stop dragging and deletion. To place the stop on proper position it also receives the same width
as StopsView
and to support stop removing and prevent user from deleting the last two stops we need access to full stops array. Also we need full array to sort it during drag gesture because SwiftUI requires stops to be properly ordered based on location while rendering GradientView
.
There is a nice hint which I found while implementing this view. You may already know that order of view modifiers matters in SwiftUI because every modifier returns some kind of View
and the next one receives already modified view. If you’re not familiar with this behavior try to make Rectangle
in SwiftUI and add .foregroundColor
and .padding
in different order you’ll get different results. And for me it was clear with UI related modifiers. But I was surprised when I got infinite loop, 100% CPU load and stuck UI during drag gesture when .gesture
modifier was placed before .position
one. I’m still not sure why this happened but since view’s body
is called every frame while dragging there was some kind of position conflict I guess. Gesture’s .onChanged
action was infinitely called as well as body
after it. Maybe it was because gesture updates position but the actual update comes only on the next frame and .position
modifier places the view on previous position. So the resulting view doesn’t receive any position update and gesture tries to update it again and again. If you can explain this strange behavior from engineering perspective but not from magical one please add it to comments I would really appreciate it :)
In this view I also used @GestureState
to store initial location of the stop when drag begins. This wrapper is very useful when you work with gestures and need to store some transient variable during gesture recognition. You need to implement .updating
method for each @GestureState
. Its closure receives current state of the whole gesture with initial location, translation and so on, current state of @GestureState
variable as inout
parameter and animation transaction. So you update this inout
parameter inside closure and it is stored in state variable. Pretty weird API as for me but it works. Another advantage of @GestureState
is that it automatically reset to initial value when gesture ends.
And on tap gesture we simply assign current stop’s ID to selectedStopID
variable. Pretty easy. The final view before combine them all together is the view which shows selected stop color value and allows to change it.
SelectedStopView
I think everything’s clear here. We just show integer value for each color channel and SwiftUI ColorPicker
. Since color picker doesn’t allow optional color we need placeholder binding with clear color for the case when no stop is selected. Let’s combine all these four views into a single one root view.
GradientBuilderView
A warm welcome to GeometryReader
! I’m not a big fan of it but sometimes we need plain old frames like in ancient UIKit times :) Here we need to know the view width to place stops properly inside it. If you’ll have fixed width for this builder you don’t need to wrap it in GeometryReader
of course.
This view also contains segmented control for gradient type selection and rotation angle slider but since they are default SwiftUI views there is no need to show them here.
And that’s it! The whole view with all the logic and UI took about 200 lines of code! The full implementation of this control and gradient shader you can find on GitHub particularly here. Thank you for reading! If you have any questions feel free to write me an email to contact@mtldoc.com or connect via Twitter @petertretyakov. Next time there will a post about Metal, I promise ;)