Blog Post

Using the SwiftUI ColorPicker on iOS and macOS

Illustration: 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.

Color Picker 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.

Color Picker on SwiftUI AppKit

ColorPicker on iPad

On iPad, the picker is displayed as a popover.

Color Picker on SwiftUI iPad

ColorPicker on iPhone

On small form factors, the picker is presented modally and adapts well to landscape mode.

Color Picker on SwiftUI iPhone Landscape

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:

Exploded View

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:

Color Picker on Mac Catalyst in Big Sur

The Show Colors… button shows the default macOS color picker (NSColorPanel):

Color Picker on Mac Catalyst in Big Sur with external Picker

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.

Related Products
Share Post
Free 60-Day Trial Try PSPDFKit in your app today.
Free Trial

Related Articles

Explore more
TUTORIALS  |  iOS • How To • Signing • PDF

How to Digitally Sign a PDF Using a YubiKey

BLOG  |  iOS • Instant • Products

Using Instant Layers for Onsite Visits

DEVELOPMENT  |  iOS • Development

Fitting Text into a Bounding Frame on iOS