Exploring SwiftUI: Custom ColorPicker using NSColorWell

For the past few weeks, I am working on Meshing, my mesh gradient app for designers and developers.

Meshing: Explore Mesh Gradients for SwiftUI!
Meshing is the tool you need for working with stunning MeshGradients in SwiftUI with ease!Key Features:- Intuitive visual editor for 2x2, 3x3, and 4x4 MeshGradients- Real-time preview of your gradient creations- Precise control over color positions with draggable points- Customizable background color- Toggle for smooth color transitions- One-click code generation for integration into your SwiftUI projectsPerfect for:- Apple Platforms Developers- UI/UX Designers- SwiftUI Enthusiasts- Anyone looking to add vibrant, complex gradients to their appsBoost Your Workflow!- Experiment with complex gradients in seconds- Save hours of manual coding and tweaking- Achieve professional-looking results quicklyI was tired of working with Previews in Xcode for mesh gradients, so I created an app for it. MeshGradients is a fun visual experience. No more guesswork or manual coding – design the perfect gradient visually and let Meshing generate the SwiftUI code for you!!System Requirements:- macOS 15.0, iOS 18.0, iPadOS 18.0, visionOS 2.0 or later- Xcode 16.0 or later (for use in SwiftUI projects)Download now and start creating beautiful, complex gradients in minutes!

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.

0:00
/0:08

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:

  1. makeUIView creates a new UIColorWell instance and sets up a target-action for color changes.
  2. updateUIView updates the UIColorWell with the current selection color. It uses the PlatformColor which is a typealias for UIColor.
  3. The Coordinator class handles the color change event and updates the selection binding so the change in the color is reflected back in the view.

I can now directly select the color from the control point!

0:00
/0:14

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:

  1. I use NSColorWell instead of UIColorWell with the typealias PlatformColorWell.
  2. The method names are slightly different (makeNSView instead of makeUIView, etc.).
  3. The way the target and action are set for color changes is different in AppKit.

This is how it looks:

0:00
/0:09

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:

0:00
/0:27

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!