Skip to content

Instantly share code, notes, and snippets.

@mykolaharmash
Last active February 7, 2025 17:15
Show Gist options
  • Save mykolaharmash/c06b9828aa8e9405fe75b50d55e586fb to your computer and use it in GitHub Desktop.
Save mykolaharmash/c06b9828aa8e9405fe75b50d55e586fb to your computer and use it in GitHub Desktop.
Color Slider Component (SwiftUI)
// A Color Slider that is similar to one in
// iOS 18+ when configuring a tinted Home Screen
// Full video-walktrough on building this component: https://youtu.be/VFVyN5hNW24
import SwiftUI
fileprivate let SLIDER_HEIGHT = 34.0
fileprivate let KNOB_DIAMETER = SLIDER_HEIGHT + 4.0
struct ColorSlider: View {
@Binding var value: Double
let colorList: [Color]
@Environment(\.self) private var environment
@State private var currentOffset = 0.0
@State private var isDragging: Bool = false
@GestureState private var dragTranslation = 0.0
var body: some View {
GeometryReader { container in
let sliderWidth = container.size.width - KNOB_DIAMETER
Capsule()
.fill(
LinearGradient(
colors: colorList,
startPoint: .leading,
endPoint: .trailing
)
)
.strokeBorder(Color.black.opacity(0.1), lineWidth: 2)
.overlay(
Circle()
.fill(calculateKnobColor(value: value, colorList: colorList))
.strokeBorder(Color.white, lineWidth: isDragging ? 8 : 5)
.animation(.easeIn(duration: 0.1), value: isDragging)
.frame(height: KNOB_DIAMETER)
.compositingGroup()
.shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1)
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1.5)
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
.offset(
x: clampOffset(
offset: currentOffset + dragTranslation,
sliderWidth: sliderWidth
)
)
.gesture(
DragGesture(minimumDistance: 0)
.updating($dragTranslation) { dragValue, state, _ in
isDragging = true
state = dragValue.translation.width
value = convertOffsetToValue(
offset: clampOffset(offset: currentOffset + state, sliderWidth: sliderWidth),
sliderWidth: sliderWidth
)
}
.onEnded { dragValue in
isDragging = false
currentOffset = clampOffset(
offset: currentOffset + dragValue.translation.width,
sliderWidth: sliderWidth
)
}
)
)
.onAppear {
currentOffset = convertValueToOffset(value: value, sliderWidth: sliderWidth)
}
}
.frame(height: SLIDER_HEIGHT)
}
private func clampOffset(offset: Double, sliderWidth: Double) -> Double {
min(max(offset, -1 * sliderWidth / 2.0), sliderWidth / 2.0)
}
private func convertOffsetToValue(offset: Double, sliderWidth: Double) -> Double {
return (offset / sliderWidth) + 0.5
}
private func convertValueToOffset(value: Double, sliderWidth: Double) -> Double {
return (value - 0.5) * sliderWidth
}
private func calculateKnobColor(value: Double, colorList: [Color]) -> Color {
let valueIndex = Double(colorList.count - 1) * value
let leadingColorIndex = Int(floor(valueIndex))
let trailingColorIndex = Int(ceil(valueIndex))
let proportion = valueIndex - Double(leadingColorIndex)
let resolvedLeadingColor = colorList[leadingColorIndex].resolve(in: environment)
let resolvedTrailingColor = colorList[trailingColorIndex].resolve(in: environment)
return Color(
red: Double(resolvedLeadingColor.red) * (1 - proportion) + Double(resolvedTrailingColor.red) * proportion,
green: Double(resolvedLeadingColor.green) * (1 - proportion) + Double(resolvedTrailingColor.green) * proportion,
blue: Double(resolvedLeadingColor.blue) * (1 - proportion) + Double(resolvedTrailingColor.blue) * proportion
)
}
}
#Preview {
@Previewable @State var hue: Double = 0.3
@Previewable @State var lightness: Double = 0.7
VStack(spacing: 27) {
// Text("\(value)")
ColorSlider(
value: $hue,
colorList: stride(from: 0, through: 1, by: 0.2).map {
Color(hue: $0, saturation: 0.8, brightness: 1)
}
)
ColorSlider(
value: $lightness,
colorList: [
Color(hue: hue, saturation: 0.1, brightness: 1),
Color(hue: hue, saturation: 1.0, brightness: 0.7)
]
)
}
.padding(.horizontal, 24)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment