Exploring SwiftUI: Mixing Colors by Creating a Color Mixing Game
I have been playing with mesh gradients for over a month now. Let me take a pause, and focus on..drum rolls...mixing colors!
I wondered how digital colors blend. That curiousity led me to create a fun little game using SwiftUI's new color mixing feature. Let me walk you through it!
During WWDC 2024, SwiftUI introduced a new method called mix(with:by:in:)
on Color
that lets you blend two colors together.
I tried out some basic examples but was not happy enough to write a post about it. Then, as someone who has created a gradient and color game, the next step was probably obvious: what better way to understand this than by making a game?
How the Game Works
You see a target color, and your job is to mix two given colors to match it. Sounds simple, right? Well, getting it close to 100 is much trickier than you might think!
The game uses three main colors. There is the target color, the one that you aiming to mix. Then there are two base colors. You mix these colors to try and match the target with a slider.
The slider represents the mix ratio between the two base colors. Slide it all the way left, and you get 100% of the first color. All the way to the right and that is 100% of the second color. Anywhere in between gives you a mix of both.
Mixing
The heart of the game is this line using SwiftUI's new method:
mixedColor = baseColor1.mix(with: baseColor2, by: mixRatio)
Let's break it down based on the initializer:
func mix(
with rhs: Color,
by fraction: Double,
in colorSpace: Gradient.ColorSpace = .perceptual
) -> Color
baseColor1
is the first color. As it is a method on theColor
itself, this is calledself
in the method definition.baseColor2
is the second color, known asrhs
in the method.mixRatio
is the value from our slider, which corresponds tofraction
in the method.
The mix
method blends these colors together. Here is what each part does:
with: baseColor2
tells the method which color to mix with the first color.by: mixRatio
determines how much of each color to use. IfmixRatio
is 0.3, you get 70% of the first color and 30% of the second.- There is an optional third parameter,
in:
, which defaults to.perceptual
. This decides how the colors are mixed.
The method returns a new Color
which is a mix of the two input colors. We can think of it as a color cooking recipe: take some of color A, add some of color B, and you get a new color C.
Color Spaces
The color space gives us a choice of how to mix these colors. We can mix them in the "device" color space or the "perceptual" color space.
The device color space is like mixing paint by numbers. Straightforward but can sometimes give unexpected results. The perceptual color space is smarter. It tries to mix colors the way our eyes expect to see them mixed. Usually the better choice for smooth, natural-looking blends.
Moving Forward
Creating this game did taught me a lot. I learned how digital colors work and how to use this simple yet useful method to blend colors.
I will think more about this idea on how to make it addictive and then add it to Chroma Game for an iOS 18 release.
Give it a try. Build the game and play around with it. Here is the full code:
import SwiftUI
struct ColorMixingGame: View {
@State private var targetColor: Color
@State private var baseColor1: Color
@State private var baseColor2: Color
@State private var mixRatio = 0.5
@State private var currentScore: Double = 0
init() {
let (target, color1, color2) = Self.generateGameColors()
_targetColor = State(initialValue: target)
_baseColor1 = State(initialValue: color1)
_baseColor2 = State(initialValue: color2)
}
var mixedColor: Color {
baseColor1.mix(with: baseColor2, by: mixRatio)
}
}
extension ColorMixingGame {
var body: some View {
VStack(spacing: 20) {
Text("Color Mixing Game")
.font(.title)
.fontWidth(.expanded)
ColorLabel(color: targetColor, text: "Target")
ColorLabel(color: mixedColor, text: "Your Mix")
HStack {
RoundedRectangle(cornerRadius: 12)
.fill(baseColor1)
RoundedRectangle(cornerRadius: 12)
.fill(baseColor2)
}
Slider(value: $mixRatio)
Text("Score: \(currentScore, specifier: "%.2f")%")
.font(.headline)
.fontWidth(.expanded)
HStack {
Button("Check Score") {
currentScore = calculateScore(target: targetColor, mixed: mixedColor)
}
.fontWidth(.expanded)
.buttonStyle(.borderedProminent)
Button("New Colors") {
resetGame()
}
.fontWidth(.expanded)
.buttonStyle(.bordered)
}
}
.padding()
}
}
extension ColorMixingGame {
private static func generateGameColors() -> (target: Color, base1: Color, base2: Color) {
let color1 = Color.random()
let color2 = Color.random()
let targetMixRatio = Double.random(in: 0...1)
let targetColor = color1.mix(with: color2, by: targetMixRatio)
return (targetColor, color1, color2)
}
private func calculateScore(target: Color, mixed: Color) -> Double {
let targetComponents = target.rgbComponents()
let mixedComponents = mixed.rgbComponents()
let componentDifferences = zip(targetComponents, mixedComponents).map { abs($0 - $1) }
let averageDifference = componentDifferences.reduce(0, +) / Double(componentDifferences.count)
return (1 - averageDifference) * 100
}
private func resetGame() {
let (target, color1, color2) = Self.generateGameColors()
targetColor = target
baseColor1 = color1
baseColor2 = color2
mixRatio = 0.5
currentScore = 0
}
}
#Preview {
ColorMixingGame()
}
extension Color {
static func random() -> Color {
Color(
red: Double.random(in: 0...1),
green: Double.random(in: 0...1),
blue: Double.random(in: 0...1)
)
}
func rgbComponents() -> [Double] {
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return [Double(red), Double(green), Double(blue)]
}
}
struct ColorLabel: View {
let color: Color
let text: String
var body: some View {
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 12)
.fill(color)
Text(text)
.font(.callout)
.fontWidth(.expanded)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
UnevenRoundedRectangle(
cornerRadii: .init(
topLeading: 0,
bottomLeading: 8,
bottomTrailing: 8,
topTrailing: 0
),
style: .continuous
)
.fill(.ultraThinMaterial)
)
}
}
}
Happy color mixing!