SwiftUI ColorPicker — der Trick mit dem unsichtbaren Overlay

Ursprünglich erschienen auf Medium im Juli 2024.

Der ColorPicker in SwiftUI ist funktional solide, aber optisch kaum gestaltbar. Die einzige anklickbare Fläche ist ein kleiner Farbkreis, und keine Modifier der Welt ändern daran etwas.


Standard ColorPicker mit eingeschränkten Designmöglichkeiten

Das Bild oben zeigt das Maximum der Customization-Möglichkeiten — und gleichzeitig den größten Nachteil: Die einzige klickbare Fläche bleibt der kleine Farbkreis rechts. Mit den Standard-Modifiern lässt sich daran nichts ändern.

Die Lösung klingt nach Hack — und ist es auch, aber ein eleganter.

Die Idee

Ein frei gestalteter Button bildet die sichtbare Oberfläche. Darüber liegt ein unsichtbar gemachter ColorPicker, der auf denselben Bereich gestreckt wird. Der User klickt auf den Button, trifft aber den unsichtbaren ColorPicker darunter, und das Farbauswahl-Sheet öffnet sich wie gewohnt.

Animation des Custom ColorPicker Buttons in Aktion

Die drei entscheidenden Modifier

ColorPicker("", selection: $backgroundColor, supportsOpacity: true)
    .labelsHidden()
    .blur(radius: 20)
    .scaleEffect(x: 20, y: 2)
    .opacity(0.014)
    .frame(maxWidth: .infinity, maxHeight: 50)
    .clipped()
ModifierWas er tut
.labelsHidden()Entfernt das automatische Label-View
.scaleEffect(x: 20, y: 2)Streckt den kleinen Farbkreis auf die Größe des darunterliegenden Buttons
.opacity(0.014)Macht den ColorPicker fast unsichtbar, aber noch touchable
.blur(radius: 20)Weichzeichner für die minimale Restsichtbarkeit
.frame().clipped()Begrenzt den gestreckten ColorPicker auf den Button-Bereich

Der kritische Wert: 0.014

Opacity 0.0 deaktiviert Tap-Gesten komplett — die View wird von UIKit als nicht-interaktiv behandelt. 0.01 ist zu wenig, der ColorPicker reagiert nicht mehr zuverlässig. 0.02 lässt einen leichten Schatten sichtbar werden. Der Wert 0.014 ist empirisch ermittelt: unsichtbar genug fürs Auge, aber gerade noch sichtbar genug, damit UIKit die Geste weiterleitet.


Optionaler Farb-Indikator

Der Regenbogen-Ring rechts im Button besteht aus drei kaskadierten Kreisen — die gewählte Farbe als Füllung, ein weißer Rand und ein animierter Regenbogen-Gradient:

Circle()
    .fill(backgroundColor)
    .background(
        Circle().stroke(.white, lineWidth: 4)
            .background(
                Circle()
                    .stroke(LinearGradient(
                        colors: [.yellow, .orange, .red, .purple, .blue, .green],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing), lineWidth: 10)
                    .rotationEffect(.degrees(rotation))
                    .animation(.linear(duration: 2)
                        .repeatForever(autoreverses: false), value: rotation)
                    .onAppear { rotation = 360 }
            )
    )
    .frame(width: 18, height: 18)

Drei Kreise ineinander, der äußere dreht sich endlos. Im Ergebnis sieht es aus wie ein professioneller Color-Wheel-Button — obwohl es technisch ein Stack aus drei .background()-Modifiern ist.


Vollständiges Beispiel

Der komplette Button besteht aus drei Schichten: Text links als sichtbare Oberfläche, der Farb-Indikator rechts und der unsichtbare ColorPicker als Overlay über allem.

struct PickerBootCamp: View {
    @State var backgroundColor: Color = .pink
    @State private var rotation = 0.0
    let rainbowColors = [Color.yellow, .orange, .red, .purple, .blue, .green]

    var body: some View {
        ZStack {
            backgroundColor.ignoresSafeArea()
            VStack {
                HStack {
                    Text("Select background color")
                        .font(.headline)
                        .foregroundColor(.pink)
                        .padding(.horizontal)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .overlay(
                            HStack {
                                Spacer()
                                Circle()
                                    .fill(backgroundColor)
                                    .background(
                                        Circle().stroke(.white, lineWidth: 4)
                                            .background(
                                                Circle()
                                                    .stroke(LinearGradient(
                                                        colors: rainbowColors,
                                                        startPoint: .topLeading,
                                                        endPoint: .bottomTrailing),
                                                        lineWidth: 10)
                                                    .rotationEffect(.degrees(rotation))
                                                    .animation(.linear(duration: 2)
                                                        .repeatForever(autoreverses: false),
                                                        value: rotation)
                                                    .onAppear { rotation = 360 }
                                            )
                                    )
                                    .frame(width: 18, height: 18)
                                    .padding(.trailing, 6)
                                    .padding()
                            }
                            .overlay(
                                ColorPicker("", selection: $backgroundColor,
                                            supportsOpacity: true)
                                    .labelsHidden()
                                    .blur(radius: 20)
                                    .scaleEffect(x: 20, y: 2)
                                    .opacity(0.014)
                                    .frame(maxWidth: .infinity, maxHeight: 50)
                                    .clipped()
                            )
                        )
                }
                .frame(maxWidth: .infinity, minHeight: 54)
                .background(.white)
                .cornerRadius(10)
                .shadow(color: .gray.opacity(0.2), radius: 10)
                Spacer()
            }
            .frame(width: 300)
        }
    }
}

Kein UIKit, kein UIViewRepresentable — reines SwiftUI mit einem sauberen Overlay und einem sorgfältig kalibrierten Opacity-Wert.


Manchmal ist die eleganteste Lösung ein gut getarnter Hack, und solange er funktioniert und lesbar bleibt, spricht nichts dagegen.