Using the SwiftUI ColorPicker on iOS and macOS
While macOS has offered a system-provided color picker since OS X 10.0 Cheetah, iOS developers had to wait a bit longer. With iOS 14, Apple added UIColorPickerViewController
and UIColorWell
, which somewhat correspond to their older AppKit parents, NSColorPanel
and NSColorWell
.
I’ve been taking a closer look at this control, and of course it’s full of surprises.
ColorPicker in SwiftUI
The ColorPicker
view in SwiftUI is similar to UIKit’s UIColorWell
control. There’s no native way to manually present the color picker, but it’s easy to bridge and present UIColorPickerViewController
if needed:
struct ContentView: View { @State var color = Color.white var body: some View { ColorPicker("Set color", selection: $color) } }
The control can bind to either Color
or CGColor
, and there’s an option for supportsOpacity
that defaults to true
. No other configuration options exist.
ColorPicker on macOS
The color picker looks just like the one we’re used to on macOS, and its behavior is the same on both SwiftUI Mac and SwiftUI Catalyst.
ColorPicker on iPad
On iPad, the picker is displayed as a popover.
ColorPicker on iPhone
On small form factors, the picker is presented modally and adapts well to landscape mode.
For more advanced use cases, we need to look at the UIKit API. I’ll skip UIColorWell
, as it behaves almost exactly like its SwiftUI counterpart, and I’ll instead focus on UIColorPickerViewController
.
Using UIColorPickerViewController in UIKit
Apple’s UIColorPickerViewController
has a compact API and is straightforward to use. You can use the delegate pattern to be notified about selectedColor
property changes, or you can use KVO.
Using Combine’s KVO wrapper is an extremely elegant way to receive color changes:
let colorPicker = UIColorPickerViewController() cancellable = picker.publisher(for: \.selectedColor) .sink { color in print("New color set: \(color)") } present(picker, animated: true, completion: nil)
While Apple also offers a colorPickerViewControllerDidFinish
delegate call, this method isn’t called when the picker is presented as a popover and dismissed by tapping outside the view — the call is only called when dismissing the control via the Done button when the picker is presented modally. Therefore, I question the usefulness of this delegate.
Presenting UIColorPickerViewController
While this isn’t documented, Apple designed the color picker to be presented modally, and everything works as expected. If we try to instead push the picker onto a navigation controller, the default behavior is pretty bad:
It looks like Apple hasn’t tested this use case. The background color is missing, and there’s a weird animation related to safeAreaInsets
. The color picker is hosted as a remote view controller, which might explain some of these problems: Remote view controllers in UIKit are finicky and often have interesting bugs.
However, we can mitigate this somewhat if we embed UIColorPickerViewController
into a custom container:
/// This is a wrapper to enable using UIKit's color picker via pushing in a navigation controller. /// Presenting via modal presentation doesn't require a wrapper. class ColorPickerWrapperController: UIViewController { /// There can only be one VC at all times, especially because Catalyst uses this with an external window. static let shared = UIColorPickerViewController() @objc let colorPicker = ColorPickerWrapperController.shared override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) // This is the minimum size the picker will work in. self.preferredContentSize = CGSize(width: 320, height: 500) } override func viewDidLoad() { super.viewDidLoad() add(childViewController: colorPicker) // Apple forgot defining a color for the picker. view.backgroundColor = .white } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
Using this setup, the color picker can be pushed on a navigation controller stack. There can be a short flickering as the remote plugin is loaded, but it’s usable. These issues have been reported under FB8980868.
Catalyst is different
In a surprising decision, Apple shows a completely different color picker when your app runs on the Mac, and it doesn’t matter if the app runs via Catalyst (Scale Interface to Match iPad) or Catalyst (Optimize Interface for Mac). But most surprising is that even in the new iOS emulation mode (Apple Silicon only), it uses the AppKit look. You can test this by enabling Show Designed for iPad in Xcode and selecting the new Mac target.
The Mac version uses NSColorPickerMatrixView
, an AppKit view, which is bridged to UIKit via _UINSView
. Xcode’s view debugger doesn’t display hosted AppKit views inside the UIView hierarchy, but we can use LLDB to dig into the hierarchy:
This is puzzling and will cause compatibility issues, as the color picker works completely different, and it doesn’t look great at all:
However, when the picker is presented directly on top of content, it looks good and fits into the Mac:
The Show Colors… button shows the default macOS color picker (NSColorPanel
):
However there’s a gotcha: The Show Colors… button dismisses the picker and shows the default Mac color picker instead. The idea is that the color can be tweaked further using this window. However, this only works if you ensure the UIColorPickerViewController
is kept around. I reported this surprising behavior as FB8981193 to Apple, and it indeed confirmed that the color picker controller must be kept around for this to work:
class ColorPickerSingleton { /// There can only be one VC at all times, especially because Catalyst uses this with an external window. static let shared = UIColorPickerViewController()
If you use UIColorWell
, this is automatically handled for you.
If the color picker is used via Catalyst’s scaled mode, then this scaling reduces the size of the picker to 0.77. This bug is reported via FB8980868. I recommend switching to the Optimize Interface for Mac scaling mode to make the color picker normal sized. (Be careful about surprising crashes when enabling this mode.)
Bonus: Understanding AppKit’s NSColorPanel
To understand why it’s necessary to use UIColorPickerViewController
like a singleton in Mac Catalyst, we need to look at AppKit. The NSColorPanel
is designed to be a singleton and can only be displayed once per app:
func applicationDidFinishLaunching(notification: NSNotification) { let colorPanel = NSColorPanel.sharedColorPanel() colorPanel.setTarget(self) colorPanel.setAction(Selector("colorDidChange:")) colorPanel.setAction.makeKeyAndOrderFront(self) colorPanel.setAction.continuous = true } func colorDidChange(sender: AnyObject) { if let colorPicker = sender as? NSColorPanel { print("New color: \(colorPicker.color\)") } }
This explains why we should keep an instance of UIColorPickerViewController
around: to keep getting notifications.
Conclusion
Apple’s new color picker is a great addition to its platform. We’ve been taking a closer look at this control and how it works in SwiftUI, UIKit, AppKit, and Catalyst, and of course it’s full of surprises. It hasn’t been widely tested and misses some polish, but it’s a good and simple choice for selecting colors.
FAQ
Here are a few frequently asked questions about the color picker.
How can I bind a color to the SwiftUI ColorPicker?
You can bind the ColorPicker
to a Color
or CGColor
property using the @State
attribute in SwiftUI.
Can I disable opacity control in the SwiftUI ColorPicker?
Yes, you can disable opacity control by setting the supportsOpacity
parameter to false
.
What is the recommended way to present UIColorPickerViewController in UIKit?
UIColorPickerViewController
is designed to be presented modally. Using it in a navigation stack can cause issues with layout and animations.
Does ColorPicker in SwiftUI differ between iOS and macOS?
Yes, ColorPicker
behaves differently depending on the platform. On macOS, it resembles the macOS color panel, while on iOS devices, it adapts to the screen size (popover on iPad, modal on iPhone).
Can I use ColorPicker in Catalyst apps?
Yes, but be aware that it may show a different interface on macOS due to the way Catalyst bridges UIKit to AppKit.