Created
May 3, 2025 09:19
-
-
Save Koshimizu-Takehito/75d0b85d99605f7a12bd25a2662bdcac to your computer and use it in GitHub Desktop.
Displays the provided value inside a capsule‑shaped badge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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