For the past few weeks, I am working on Meshing, my mesh gradient app for designers and developers.
The app has the colors defined in the sidebar for the mesh gradient, which users can update using SwiftUI's native ColorPicker
. However, this felt redundant because the gradient already had control points with colors.
I wanted to reduce the clutter on the UI by removing the sidebar color list entirely and allowing users to interact directly with the control points to change colors.
The ideal solution seemed simple: make the control points open the ColorPicker
directly.
But, SwiftUI does not provide a way to use the picker's action without its label. This limitation set me to explore a custom solution.
Update: After posting this, I realised using .labelsHidden()
just works fine on iOS, and I did not had to use UIColorWell
for it. But, the UI of ColorPicker
on macOS is not something I like, so I will let this post be there as it is.
Back to UIKit/AppKit!
UIColorWell
and NSColorWell
After some research, I discovered UIColorWell
for iOS/iPadOS and NSColorWell
for macOS. ColorPicker
is likely a wrapper over them. I decided to provide native experience and set out to create a custom, cross-platform color picker.
The first step was to set up typealiases
for code that works across iOS, iPadOS, and macOS with minimal platform-specific conditionals.
import SwiftUI
#if os(macOS)
import AppKit
typealias PlatformColorWell = NSColorWell
typealias PlatformColor = NSColor
#else
import UIKit
typealias PlatformColorWell = UIColorWell
typealias PlatformColor = UIColor
#endif
These let me use PlatformColorWell
and PlatformColor
throughout the code, and the compiler will use the appropriate type based on the target platform.
Implementing for iOS and iPadOS
For iOS and iPadOS, I create a UIViewRepresentable
to wrap UIColorWell
:
#if os(iOS) || os(visionOS)
struct MeshingColorPicker: UIViewRepresentable {
@Binding var selection: Color
func makeUIView(context: Context) -> PlatformColorWell {
let colorWell = PlatformColorWell()
colorWell.addTarget(context.coordinator, action: #selector(Coordinator.colorChanged(_:)), for: .valueChanged)
colorWell.accessibilityLabel = "Color Picker"
return colorWell
}
func updateUIView(_ uiView: PlatformColorWell, context: Context) {
uiView.selectedColor = PlatformColor(selection)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: MeshingColorPicker
init(_ parent: MeshingColorPicker) {
self.parent = parent
}
@MainActor @objc func colorChanged(_ sender: PlatformColorWell) {
if let color = sender.selectedColor {
parent.selection = Color(color)
}
}
}
}
#endif
Breaking it down:
makeUIView
creates a newUIColorWell
instance and sets up a target-action for color changes.updateUIView
updates theUIColorWell
with the currentselection
color. It uses thePlatformColor
which is a typealias forUIColor
.- The
Coordinator
class handles the color change event and updates theselection
binding so the change in the color is reflected back in the view.
I can now directly select the color from the control point!
Implementing for macOS
The macOS implementation is similar, but uses NSViewRepresentable
to wrap NSColorWell
:
#if os(macOS)
struct MeshingColorPicker: NSViewRepresentable {
@Binding var selection: Color
func makeNSView(context: Context) -> PlatformColorWell {
let colorWell = PlatformColorWell()
colorWell.target = context.coordinator
colorWell.action = #selector(Coordinator.colorChanged(_:))
colorWell.setAccessibilityLabel("Color Picker")
return colorWell
}
func updateNSView(_ nsView: PlatformColorWell, context: Context) {
nsView.color = PlatformColor(selection)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: MeshingColorPicker
init(_ parent: MeshingColorPicker) {
self.parent = parent
}
@MainActor @objc func colorChanged(_ sender: PlatformColorWell) {
parent.selection = Color(sender.color)
}
}
}
#endif
The structure is very similar to the iOS version, with a few key differences:
- I use
NSColorWell
instead ofUIColorWell
with the typealiasPlatformColorWell
. - The method names are slightly different (
makeNSView
instead ofmakeUIView
, etc.). - The way the target and action are set for color changes is different in AppKit.
This is how it looks:
Oh.
That is horrible.
Styling for macOS
Looks like the macOS version required some additional styling to make it less uglier:
.frame(width: 25, height: 35)
.clipShape(Circle())
.overlay(content: {
Circle()
.stroke(Color.primary.gradient, lineWidth: 2)
.padding(2)
})
.overlay(content: {
Circle()
.stroke(
AngularGradient(
colors: [.teal, .blue, .indigo, .purple, .pink, .red, .orange, .yellow, .green, .teal],
center: .center
),
lineWidth: 2
)
})
This code clips the color well to a circle shape and adds a stylized border similar to what is there on iOS and iPadOS.
Creating the Custom Color Picker
Finally, here is the MeshingColorPoint
that houses both the view conditionally:
public struct MeshingColorPoint: View {
@Binding var selection: Color
public init(selection: Binding<Color>) {
self._selection = selection
}
public var body: some View {
#if os(macOS)
MeshingColorPicker(selection: $selection)
.frame(width: 25, height: 35)
.clipShape(Circle())
.overlay(content: {
Circle()
.stroke(Color.primary.gradient, lineWidth: 2)
.padding(2)
})
.overlay(content: {
Circle()
.stroke(
AngularGradient(
colors: [.teal, .blue, .indigo, .purple, .pink, .red, .orange, .yellow, .green, .teal],
center: .center
),
lineWidth: 2
)
})
#else
ColorPicker(selection: $selection)
.labelsHidden()
#endif
}
}
This structure uses a @Binding
to allow two-way communication with the parent view that also has the label.
Update: I have directly used the ColorPicker
with .labelsHidden()
after I realised that can be done, too.
And here is the better macOS one:
Moving Forward
I am a happy man now with this custom color picker in place that looks same across iOS, iPadOS, and macOS.
Have you faced similar challenges working with ColorPicker
? How did you overcome them? I would love to hear about your experiences and any creative ways you have found to implement it! Reach out to me on X (formerly Twitter) on @rudrankriyam!
Happy coloring!