Skip to content

Instantly share code, notes, and snippets.

@Koshimizu-Takehito
Created May 3, 2025 09:19
Show Gist options
  • Save Koshimizu-Takehito/75d0b85d99605f7a12bd25a2662bdcac to your computer and use it in GitHub Desktop.
Save Koshimizu-Takehito/75d0b85d99605f7a12bd25a2662bdcac to your computer and use it in GitHub Desktop.
Displays the provided value inside a capsule‑shaped badge.
import Observation
/// An observable view‑model that drives a numeric badge.
///
/// `BadgeModel` keeps track of the current count (`number`) and the
/// badge’s visual appearance (`style`). It also exposes two derived
/// properties that make it easy to bind the model to SwiftUI controls:
///
/// * `slider` – Bridges the `Int`‐based `number` to floating‑point
/// controls such as `Slider`.
/// * `value` – Returns the string that should actually appear inside
/// the badge. Counts over 999 collapse to `"+999"`; negative values
/// yield `nil`, which you can interpret as “hide the badge.”
@Observable
final class BadgeModel {
/// The raw count shown on the badge.
var number: Int
/// The visual styling applied to the badge.
var style: BadgeStyle
/// A proxy that lets `Slider` or other `Double`‑based controls
/// read and mutate `number` without manual conversions.
var slider: Double {
get { Double(number) }
set { number = Int(newValue) }
}
/// The string representation that appears inside the badge, or
/// `nil` when the badge should be hidden.
///
/// * 0 … 999 → the number itself (`"57"`, `"999"`, …)
/// * 1000 + → `"+999"` (caps the visual length)
/// * < 0 → `nil` (badge off)
var value: String? {
switch number {
case 0...999:
return number.formatted()
case 1000...:
return "+999"
default:
return nil
}
}
init(number: Int = 0, style: BadgeStyle = BadgeStyle()) {
self.number = number
self.style = style
}
}
import SwiftUI
/// A configurable style container for badge‑like views.
///
/// `BadgeStyle` groups the three most common visual attributes—`font`,
/// `weight`, and `tint`—into a single value that you can pass around or
/// store. Use it to ensure visual consistency across multiple badges.
struct BadgeStyle: Hashable {
/// The typeface used for badge text.
var font: Font = .largeTitle
/// The font weight applied to the badge text.
var weight: Font.Weight = .bold
/// The foreground color of the badge.
var tint: Color = .red
/// Restores the style to the built‑in defaults.
mutating func reset() {
self = BadgeStyle()
}
}
import SwiftUI
// MARK: - Demo Screen
/// A playground‑style screen that showcases `BadgeView` along with
/// interactive controls for both value and appearance.
struct BadgeDemoScreen: View {
/// The observable model that drives the badge.
@State private var model = BadgeModel(number: -1)
var body: some View {
VStack {
BadgeView(value: model.value)
.modifier(BadgeStyleModifier(style: model.style))
.frame(maxHeight: .infinity)
BadgeControl(model: model)
}
.padding()
.onAppear {
model.number = 0
}
}
}
// MARK: - Badge View
/// Displays the provided value inside a capsule‑shaped badge.
struct BadgeView<Value>: View {
// The value displayed inside the badge; `nil` hides the badge.
var value: Value?
@State private var labelSize: CGSize = .zero
@State private var badgeSize: CGSize = .zero
var body: some View {
ZStack {
if let value = value.map(String.init(describing:)) {
Text(value)
}
}
.monospacedDigit()
.fixedSize()
.onGeometryChange(for: CGSize.self, of: \.size) {
// Capture the text size
labelSize = $0
}
.padding(.vertical, verticalPadding)
.padding(.horizontal, horizontalPadding)
.onGeometryChange(for: CGSize.self, of: \.size) {
// Capture the badge size *after* padding
badgeSize = $0
}
.frame(width: badgeWidth, height: badgeHeight)
.background(.tint)
.clipShape(.capsule)
.animation(.default, value: labelSize)
.animation(.default, value: badgeSize)
}
/// Ensures `width ≥ height` so the capsule never pinches horizontally.
private var badgeWidth: CGFloat { max(badgeSize.width, badgeSize.height) }
private var badgeHeight: CGFloat { badgeSize.height }
private var verticalPadding: CGFloat { 0.2 * labelSize.height }
private var horizontalPadding: CGFloat { 0.4 * labelSize.height }
}
// MARK: - Style Modifier
/// Applies a `BadgeStyle` to any view, allowing reuse across the app.
struct BadgeStyleModifier: ViewModifier {
var style: BadgeStyle
func body(content: Content) -> some View {
content
.tint(style.tint)
.font(style.font)
.fontWeight(style.weight)
.foregroundStyle(.white)
}
}
// MARK: - Control Panel
/// Hosts both the style selectors and the numeric controls.
private struct BadgeControl: View {
@Bindable var model: BadgeModel
var body: some View {
VStack {
Group {
BadgeStyleControl(model: model)
BadgeValueControl(model: model)
}
.pickerStyle(.segmented)
.padding()
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 12))
}
}
}
// MARK: Style Controls
/// Lets the user tweak font, weight, and tint of the badge.
private struct BadgeStyleControl: View {
@Bindable var model: BadgeModel
var body: some View {
VStack {
Picker("Font", selection: $model.style.font.animation()) {
Text("Large").tag(Font.largeTitle)
Text("Title").tag(Font.title)
Text("Body").tag(Font.body)
Text("Caption").tag(Font.caption)
}
Picker("Font Weight", selection: $model.style.weight.animation()) {
Text("Black").tag(Font.Weight.black)
Text("Bold").tag(Font.Weight.bold)
Text("Regular").tag(Font.Weight.regular)
Text("Ultra‑Light").tag(Font.Weight.ultraLight)
}
Picker("Tint", selection: $model.style.tint.animation()) {
Text("Red").tag(Color.red)
Text("Blue").tag(Color.blue)
Text("Yellow").tag(Color.yellow)
Text("Purple").tag(Color.purple)
}
Button("Reset") {
withAnimation { model.style.reset() }
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top)
}
}
}
// MARK: Value Controls
/// Provides a stepper and slider to mutate the badge’s numeric value.
private struct BadgeValueControl: View {
@Bindable var model: BadgeModel
var body: some View {
VStack {
Stepper("", value: $model.number, in: -1...1000)
Slider(value: $model.slider, in: -1...1000)
Button("Reset") {
withAnimation { model.number = 0 }
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
// MARK: - Preview
#Preview {
BadgeDemoScreen()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment