Secure Entry
A glass password field with a reveal bubble, a strength chip and requirement chips that turn sage with a check as each rule passes.
Live preview
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
/// Liquid glass password field with strength meter, live requirements, and inline error.
///
/// The field is a glass shape with a round glass reveal bubble joined to it. Everything below it buds out of the
/// field when it appears and melts back into it when it goes: the error chip, joined just under it, and, apart from
/// it, the strength chip and the requirement chips.
///
/// - Parameters:
/// - label: Static caption above the field, also the placeholder and accessibility label.
/// - text: Bound password text.
/// - error: Validation message. Setting it from `nil` shakes the field once with an error haptic, tints it with `Style.error`, buds an error chip out of its bottom edge, and announces the message to VoiceOver. A new message for a standing error swaps in place and is announced, without a second shake; clear it to `nil` on edit so the next refused submit shakes again.
/// - showsStrength: Show the strength gauge and its label chip once text is non-empty.
/// - showsRequirements: Show the 8+ / number / symbol / mixed-case chips while focused or filled. When all four pass the field takes the success tint with a check, and the chips melt back into it a beat later.
/// - submitLabel: The Return key label, e.g. `.go` or `.next`, hidden or revealed. `nil`, the default, keeps the label set around the field, `.return` unless one is.
/// - onSubmit: Called when Return is pressed, hidden or revealed. Submit the form or move focus to the next field here.
/// - style: Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark.
public struct SecureEntry: View {
/// Colors and metrics. `.standard` is the house palette.
public struct Style: Sendable {
/// The glass tint of the field at rest. `.clear`, the default, leaves it neutral glass; an error or a complete password tints it with `error` or `success`.
public var field: Color
/// Typed text and the focus ring on neutral glass.
public var label: Color
/// Caption, placeholder and unmet requirements.
public var secondaryLabel: Color
/// The glass tint of the round reveal bubble. `.clear`, the default, leaves it neutral glass.
public var button: Color
/// Text and glyphs on tinted glass.
public var ink: Color
/// Strength tints from weak to strong, for the gauge and its chip. Four colors; shorter arrays repeat their last color.
public var strength: [Color]
/// The success tint: met requirement chips, and the field once every rule passes.
public var success: Color
/// The error tint: the field and its error chip.
public var error: Color
/// Minimum field height. The field grows with Dynamic Type, and the reveal bubble is a circle as tall as the field.
public var height: CGFloat
/// Field corner radius.
public var cornerRadius: CGFloat
/// Pass only what you want to change; `nil` keeps the house palette value.
public init(field: Color? = nil, label: Color? = nil, secondaryLabel: Color? = nil, button: Color? = nil, ink: Color? = nil, strength: [Color]? = nil, success: Color? = nil, error: Color? = nil, height: CGFloat = 56, cornerRadius: CGFloat = 18) {
self.field = field ?? .clear
self.label = label ?? adaptive(light: 0x141414, dark: 0xF4F3EF)
self.secondaryLabel = secondaryLabel ?? adaptive(light: 0x5C5A56, dark: 0xA6A49F)
self.button = button ?? .clear
self.ink = ink ?? adaptive(light: 0x141414, dark: 0x141414)
// Butter warns while the password is weak or fair; sage says it is good.
self.strength = (strength?.isEmpty == false ? strength : nil) ?? [0xFFD976, 0xFFD976, 0xA9DCB7, 0xA9DCB7].map { adaptive(light: $0, dark: $0) }
self.success = success ?? adaptive(light: 0xA9DCB7, dark: 0xA9DCB7)
self.error = error ?? adaptive(light: 0xFFD976, dark: 0xFFD976)
self.height = max(height, 44)
self.cornerRadius = cornerRadius
}
public static let standard = Style()
func strength(_ score: Int) -> Color { strength[min(max(score - 1, 0), strength.count - 1)] }
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@FocusState private var focus: Field?
@State private var isRevealed = false
@State private var shakeCount = 0
/// Set a beat after the last rule is met, so the completed set tidies away after its last tile lands.
@State private var collapsed: Bool
@Binding private var text: String
/// The parts below the field whose room is open. Each opens as its bubbles bud out of the field and closes as they
/// melt home, on the same springs, so whatever sits below moves with them instead of jumping.
@State private var open: Set<Part> = []
@State private var buds = PieceBuds()
/// The error the chip shows. It keeps the last message while the chip melts home after `error` clears.
@State private var shownError: String?
@State private var errorHeight: CGFloat = 0
@State private var rulesHeight: CGFloat = 0
/// The strength chip's height: `meterHeight`, taller at large text sizes.
@State private var strengthChip: CGFloat = 22
/// Each requirement chip's height, so each buds from straight above its own place however its rows fall.
@State private var ruleHeights: [Int: CGFloat] = [:]
/// The reveal bubble's diameter: the field's height, which grows from `Style.height` with Dynamic Type.
@State private var bubble: CGFloat
/// One line of footnote text, for the chips' rounding.
@ScaledMetric(relativeTo: .footnote) private var footnoteLine: CGFloat = 16
/// The room either side of the strength chip's label, so large type stays clear of its rounded ends.
@ScaledMetric(relativeTo: .caption2) private var chipInset: CGFloat = 4
/// The requirement chip's ring and check, sized with its label.
@ScaledMetric(relativeTo: .footnote) private var ruleMark: CGFloat = 14
private let label: String
private let error: String?
private let showsStrength: Bool
private let showsRequirements: Bool
private let submitLabel: SubmitLabel?
private let submitAction: (() -> Void)?
private let style: Style
private enum Field { case secure, plain }
/// What sits below the field: the error chip, the strength gauge and the requirement chips.
private enum Part: Hashable { case error, strength, rules }
private struct Requirement: Identifiable {
let id: String
let met: Bool
}
private static let levels = ["Weak", "Fair", "Good", "Strong"]
/// The strength chip's least height.
private let meterHeight: CGFloat = 22
/// The strength gauge's thickness, and its gap to the chip.
private let gaugeHeight: CGFloat = 8
private let meterSpacing: CGFloat = 10
/// At accessibility text sizes the strength chip moves under its gauge and the requirement chips stack one to a
/// row with room to wrap, so no label is cut short in the column beside the reveal bubble.
private var stacked: Bool { dynamicTypeSize.isAccessibilitySize }
/// The strength room's height: the chip, with the gauge above it when stacked.
private var strengthHeight: CGFloat { stacked ? gaugeHeight + meterSpacing + strengthChip : max(strengthChip, gaugeHeight) }
public init(
_ label: String = "Password",
text: Binding<String>,
error: String? = nil,
showsStrength: Bool = true,
showsRequirements: Bool = true,
submitLabel: SubmitLabel? = nil,
onSubmit: (() -> Void)? = nil,
style: Style = .standard
) {
self.label = label
self._text = text
self.error = error
self.showsStrength = showsStrength
self.showsRequirements = showsRequirements
self.submitLabel = submitLabel
self.submitAction = onSubmit
self.style = style
self._bubble = State(initialValue: style.height)
// A field that opens already complete starts collapsed, so its set never tidies away on appear.
self._collapsed = State(initialValue: showsRequirements && Self.requirements(for: text.wrappedValue).allSatisfy(\.met))
self._shownError = State(initialValue: error)
}
private var complete: Bool { showsRequirements && Self.requirements(for: text).allSatisfy(\.met) }
/// The parts that should show now. While typing, a set that just completed stays a beat so its last tile lands;
/// leaving the field tidies it at once.
private var wanted: Set<Part> {
let isFocused = focus != nil
var parts: Set<Part> = []
if error != nil { parts.insert(.error) }
if showsStrength && !text.isEmpty { parts.insert(.strength) }
if complete ? isFocused && !collapsed : showsRequirements && (isFocused || !text.isEmpty) { parts.insert(.rules) }
return parts
}
public var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let requirements = Self.requirements(for: text)
let passed = requirements.filter(\.met).count
let complete = self.complete
let completing = showsRequirements ? requirements.count : -1
let isFocused = focus != nil
let hasError = error != nil
let checked = complete && !hasError
let shape = RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous)
// The field's glass carries its state: an error, a complete password, or its own resting tint.
let fieldTint: Color? = hasError ? style.error : checked ? style.success : style.field == .clear ? nil : style.field
let ink = fieldTint == nil ? style.label : style.ink
// Every part below lines up with the field, so each buds straight down out of it.
let column = bubble + PieceLiquid.joined
// Read here, so this body observes the bubbles: the reads below sit in closures (the rooms' overlays, the grid) that are not re-run on
// their own when a bubble changes, which would let one appear without its bud.
let _ = (buds.present, buds.out)
VStack(alignment: .leading, spacing: 10) {
Text(label.uppercased())
.font(.caption.weight(.semibold))
.tracking(0.8)
.foregroundStyle(style.secondaryLabel)
PieceLiquidGroup {
VStack(alignment: .leading, spacing: 0) {
// The field and the reveal bubble are one control, so they rest joined by a liquid neck. The field
// grows with Dynamic Type and the bubble grows with it, a circle as tall as the field on its centre line.
JoinedRow(spacing: PieceLiquid.joined, minimum: style.height) {
HStack(spacing: 8) {
Group {
if isRevealed {
TextField(label, text: $text)
.focused($focus, equals: .plain)
} else {
SecureField(label, text: $text)
.focused($focus, equals: .secure)
}
}
.font(.body.weight(.semibold))
.foregroundStyle(ink)
.tint(ink)
.textContentType(.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
// Both fields share one Return key and one action, so revealing never changes what Return does.
.modifier(ReturnKeyLabel(label: submitLabel))
.onSubmit { submitAction?() }
.accessibilityLabel(label)
if checked {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(style.ink)
// The outcome blurs into the sage glass on the success haptic's beat.
.transition(motion.swap)
.accessibilityLabel("All requirements met")
}
}
.padding(.horizontal, 18)
// At least `Style.height`, taller once the text needs it, so large text sizes never clip. The
// padding stays inside the minimum at the default size, so the field rests at exactly that height.
.padding(.vertical, 10)
.frame(minHeight: style.height)
// The focus ring is the glass's own content: glass drawn over a separate ring would blur it.
// It sits a few points in from the edge, so the reveal bubble's neck never cuts through it.
.overlay {
RoundedRectangle(cornerRadius: max(style.cornerRadius - 3, 0), style: .continuous)
.strokeBorder(ink, lineWidth: 2)
.padding(3)
.opacity(isFocused ? 1 : 0)
.animation(motion.snap, value: isFocused)
.allowsHitTesting(false)
}
.pieceLiquid(shape, tint: fieldTint, interactive: false)
revealButton
}
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { bubble = $0 }
// A new error shakes the field and its bubble once, on the beat of its haptic. Reduce Motion keeps it still.
.pieceShake(trigger: shakeCount)
// Over the rooms below it, so a bubble at home sits under the field and melts into it.
.zIndex(1)
Group {
slot(.error, gap: PieceLiquid.joined, height: errorHeight, measure: { errorHeight = $0 }) {
errorChip
}
slot(.strength, gap: PieceLiquid.apart, height: strengthHeight, measure: { _ in }) {
meter(score: max(passed, 1))
}
slot(.rules, gap: PieceLiquid.apart, height: rulesHeight, measure: { rulesHeight = $0 }) {
rules(requirements, pops: !checked)
}
}
.padding(.trailing, column)
}
}
}
.fontWeight(.semibold)
.opacity(isEnabled ? 1 : 0.45)
// When several change in one update the first listed wins, so an error keeps its own role. The field's tint and
// check are the outcome, on the success haptic's beat; the chips keep their own springs.
.animation(hasError ? motion.error : motion.dismiss, value: error)
.animation(checked ? motion.success : motion.dismiss, value: checked)
.animation(motion.snap, value: passed)
// One haptic per event: the rule that completes the set plays success, not a tick as well.
.sensoryFeedback(.selection, trigger: passed) { old, new in new > old && new != completing }
.sensoryFeedback(.success, trigger: complete) { old, new in !old && new }
.sensoryFeedback(.error, trigger: shakeCount)
.onAppear { sync(wanted, animated: false) }
.onChange(of: wanted) { _, parts in sync(parts) }
.onChange(of: error) { old, new in
// An error arriving is the refusal. A parent rewording one already shown doesn't shake or buzz again.
if old == nil, new != nil { shakeCount += 1 }
guard let new else { return }
shownError = new
// VoiceOver hears each new or reworded message once, wherever its focus is.
if new != old { AccessibilityNotification.Announcement("Error: \(new)").post() }
}
.onChange(of: complete) { _, now in
if !now { collapsed = false }
}
// Cancelled when a rule is lost again or the field goes away, so the set never collapses over unmet rules.
.task(id: complete) {
guard complete else { return }
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
withAnimation(motion.dismiss) { collapsed = true }
}
}
/// The round glass bubble joined to the field's end. Its glyph blurs from one eye to the other.
private var revealButton: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
return Button(action: toggleReveal) {
ZStack {
Image(systemName: isRevealed ? "eye.slash" : "eye")
.font(.body.weight(.semibold))
.foregroundStyle(style.button == .clear ? style.label : style.ink)
.id(isRevealed)
.transition(motion.swap)
}
.animation(motion.snap, value: isRevealed)
// The square `JoinedRow` gives it: as tall as the field.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.pieceLiquid(.circle, tint: style.button == .clear ? nil : style.button, interactive: false)
.contentShape(.circle)
}
.buttonStyle(PieceLiquidPressStyle())
.accessibilityLabel(isRevealed ? "Hide password" : "Show password")
}
/// Room in the column for a part. It opens to its content's height plus `gap` and closes to nothing, on the
/// springs its bubbles bud and melt on. The content is always laid out, so its height is known before it opens.
private func slot(_ part: Part, gap: CGFloat, height: CGFloat, measure: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> some View) -> some View {
Color.clear
.frame(height: open.contains(part) ? gap + height : 0)
.frame(maxWidth: .infinity)
.overlay(alignment: .topLeading) {
content()
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measure($0) }
.padding(.top, gap)
}
}
/// How far above a bubble's place its home is: just inside the field's bottom edge, straight up through whatever
/// rooms are open above it. `depth` is the distance from the top of its own room to its centre.
private func homeOffset(above parts: [Part], depth: CGFloat, height: CGFloat) -> CGSize {
let rooms = parts.reduce(CGFloat(0)) { total, part in
guard open.contains(part) else { return total }
switch part {
case .error: return total + PieceLiquid.joined + errorHeight
case .strength: return total + PieceLiquid.apart + strengthHeight
case .rules: return total + PieceLiquid.apart + rulesHeight
}
}
// Home, shrunk, rests on the inside of the field's bottom edge. One too tall to fit inside the field (a long
// error at a large text size) hangs from just inside its top edge instead, never above it.
let shrunk = height * PieceLiquid.homeScale
return CGSize(width: 0, height: -(rooms + depth + shrunk / 2) + max(shrunk - bubble, 0))
}
/// The error chip: butter glass joined just under the field. Its message blurs from one wording to the next, and
/// it keeps the last one while it melts home.
@ViewBuilder private var errorChip: some View {
let message = error ?? shownError ?? ""
let face = HStack(spacing: 6) {
Image(systemName: "exclamationmark")
.font(.caption2.weight(.semibold))
Text(message)
.font(.footnote.weight(.semibold))
.id(message)
.transition(PieceMotion(reduceMotion: reduceMotion).swap)
}
.foregroundStyle(style.ink)
.padding(.horizontal, 12)
.padding(.vertical, 7)
ZStack(alignment: .topLeading) {
face.hidden()
if buds.contains("error") {
let out = buds.isOut("error")
face
.pieceBudContent(out: out)
// A capsule while the message fits on one line (its padded line height, with a little slack); a
// long one wraps inside rounded corners instead.
.pieceLiquid(ChipShape(maxRadius: (footnoteLine + 14) / 2 + 4), tint: out ? style.error : nil, interactive: false)
.pieceBud(out: out, home: homeOffset(above: [], depth: PieceLiquid.joined + errorHeight / 2, height: errorHeight))
.accessibilityElement(children: .ignore)
.accessibilityLabel("Error: \(message)")
}
}
}
/// A gauge whose fill and tint move with the score, and a glass chip in the same tint whose label morphs from one
/// level to the next. The chip buds out of the field as typing starts; the gauge fades in beside it, or above it
/// at accessibility text sizes.
private func meter(score: Int) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let color = style.strength(score)
let out = buds.isOut("strength")
let layout = stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: meterSpacing)) : AnyLayout(HStackLayout(spacing: meterSpacing))
return layout {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule().fill(style.secondaryLabel.opacity(0.18))
Capsule().fill(color).frame(width: proxy.size.width * CGFloat(score) / 4)
}
}
.frame(height: gaugeHeight)
// The gauge is a reading, not chrome: it fades in with the chip and out ahead of it.
.opacity(out ? 1 : 0)
.animation(out ? motion.reveal : motion.dismiss, value: out)
// The widest level sets the chip's size, so it holds still while its label morphs and the label morphs at
// full size at every text size.
ZStack {
ForEach(Self.levels, id: \.self) { level in
Text(level.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.8)
}
}
.hidden()
.padding(.horizontal, chipInset)
.padding(.vertical, 2)
.frame(minWidth: 64, minHeight: meterHeight)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { strengthChip = $0 }
.overlay {
if buds.contains("strength") {
PieceMorphText(text: Self.levels[score - 1].uppercased(), font: .caption2.weight(.semibold))
.tracking(0.8)
.foregroundStyle(style.ink)
.pieceBudContent(out: out)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.pieceLiquid(.capsule, tint: out ? color : nil, interactive: false)
.pieceBud(out: out, home: homeOffset(above: [.error], depth: PieceLiquid.apart + (stacked ? gaugeHeight + meterSpacing : 0) + strengthChip / 2, height: strengthChip))
}
}
}
// A gauge, not a toy: the fill changes length without overshoot, so the reading never wobbles.
.animation(motion.value, value: score)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Password strength")
.accessibilityValue(Self.levels[score - 1])
// Laid out even while its room is closed, so VoiceOver reads it only while the chip is there.
.accessibilityHidden(!buds.contains("strength"))
}
/// The requirement chips, two to a row (one at accessibility text sizes), resting apart. Each cell is always laid
/// out, so the grid's height is known before its chips bud.
private func rules(_ requirements: [Requirement], pops: Bool) -> some View {
let perRow = stacked ? 1 : 2
return Grid(horizontalSpacing: PieceLiquid.apart, verticalSpacing: PieceLiquid.apart) {
ForEach(Array(stride(from: 0, to: requirements.count, by: perRow)), id: \.self) { start in
GridRow {
ForEach(start..<min(start + perRow, requirements.count), id: \.self) { i in
rule(requirements[i], index: i, pops: pops)
}
}
}
}
}
/// Unmet: neutral glass with an empty ring. Met: sage glass whose check draws in after the tint lands.
/// `pops` is off for the rule that completes the set: the field's tint and check are that outcome, so they land alone.
private func rule(_ requirement: Requirement, index: Int, pops: Bool) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let id = "rule\(index)"
let out = buds.isOut(id)
// Where the chip sits in the grid: each row as tall as its tallest chip, the chip centred in it. Until the chips
// are measured, the rows share the grid's height evenly.
let perRow = stacked ? 1 : 2
let rows = (3 + perRow) / perRow
let even = max((rulesHeight - PieceLiquid.apart * CGFloat(rows - 1)) / CGFloat(rows), 0)
let rowHeight = { (row: Int) in
(row * perRow..<min(row * perRow + perRow, 4)).map { ruleHeights[$0] ?? even }.max() ?? even
}
let row = index / perRow
let top = (0..<row).reduce(CGFloat(0)) { $0 + rowHeight($1) + PieceLiquid.apart }
let face = HStack(spacing: 6) {
ZStack {
Circle().strokeBorder(style.secondaryLabel.opacity(0.6), lineWidth: 1.5)
.opacity(requirement.met ? 0 : 1)
Checkmark()
.trim(from: 0, to: requirement.met ? 1 : 0)
.stroke(style.ink, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
.padding(2)
// The check draws once the tint has landed; a lost rule clears it at once.
.animation(requirement.met ? motion.snap.delay(0.08) : motion.dismiss, value: requirement.met)
}
.frame(width: min(ruleMark, 24), height: min(ruleMark, 24))
Text(requirement.id)
.font(.footnote.weight(.semibold))
// One line beside its neighbour; stacked at accessibility sizes, a long label wraps rather than cut short.
.lineLimit(stacked ? nil : 1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(requirement.met ? style.ink : style.secondaryLabel)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, minHeight: 32, alignment: .leading)
return ZStack {
face.hidden()
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { ruleHeights[index] = $0 }
if buds.contains(id) {
face
.pieceBudContent(out: out)
// A met rule clicks into its tint on its tick's beat; a lost one drains back. The tint drains at
// home too, so a sage chip never sits over the field as it melts in. A capsule while its label fits
// on one line.
.pieceLiquid(ChipShape(maxRadius: max(32, footnoteLine) / 2 + 4), tint: out && requirement.met ? style.success : nil, interactive: false)
.pieceLiquidScale(requirement.met && !reduceMotion ? 1 : 0.98)
.animation(requirement.met ? motion.snap : motion.dismiss, value: requirement.met)
// A met rule clicks into its tint with a small dip and swell on its tick's beat, the glass and its
// label together. The trigger is the rule's state, which changes once per edit. Reduce Motion
// keeps the tint change without the pop.
.pieceLiquidPop(trigger: requirement.met, amount: requirement.met && pops ? 0.04 : 0)
.pieceBud(out: out, home: homeOffset(above: [.error, .strength], depth: PieceLiquid.apart + top + rowHeight(row) / 2, height: ruleHeights[index] ?? even))
.accessibilityElement(children: .ignore)
.accessibilityLabel(requirement.id)
.accessibilityValue(requirement.met ? "Met" : "Not met")
}
}
}
/// Opens or closes each part's room to match `parts`, budding its bubbles out of the field or melting them home.
/// `animated: false` puts them straight in place, for the first frame.
private func sync(_ parts: Set<Part>, animated: Bool = true) {
let ids: (Part) -> [String] = { part in
switch part {
case .error: ["error"]
case .strength: ["strength"]
case .rules: (0..<4).map { "rule\($0)" }
}
}
guard animated else {
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) { open = parts }
buds.place([Part.error, .strength, .rules].filter(parts.contains).flatMap(ids))
return
}
for part in [Part.error, .strength, .rules] where parts.contains(part) != open.contains(part) {
if parts.contains(part) {
withAnimation(PieceLiquid.split(reduceMotion: reduceMotion)) { _ = open.insert(part) }
Task { await buds.bloom(ids(part), reduceMotion: reduceMotion) }
} else {
withAnimation(PieceLiquid.home(reduceMotion: reduceMotion)) { _ = open.remove(part) }
Task { await buds.gather(ids(part), reduceMotion: reduceMotion) }
}
}
}
private func toggleReveal() {
let wasFocused = focus != nil
isRevealed.toggle()
// Hand focus to the replacement field so the keyboard stays up.
if wasFocused { focus = isRevealed ? .plain : .secure }
}
private static func requirements(for password: String) -> [Requirement] {
[
Requirement(id: "8+ characters", met: password.count >= 8),
Requirement(id: "A number", met: password.contains(where: \.isNumber)),
Requirement(id: "A symbol", met: password.contains { !$0.isLetter && !$0.isNumber && !$0.isWhitespace }),
Requirement(id: "Mixed case", met: password.contains(where: \.isUppercase) && password.contains(where: \.isLowercase))
]
}
/// Checkmark drawn in unit space so it scales with its frame.
private struct Checkmark: Shape {
nonisolated func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + rect.width * 0.1, y: rect.minY + rect.height * 0.55))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.4, y: rect.minY + rect.height * 0.85))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.9, y: rect.minY + rect.height * 0.2))
return path
}
}
/// A capsule whose ends stop rounding at `maxRadius`, so a chip whose label wraps onto more lines becomes a rounded
/// rectangle rather than an oval whose curve cuts into its text. A chip of one line is a plain capsule.
private struct ChipShape: Shape {
var maxRadius: CGFloat
nonisolated func path(in rect: CGRect) -> Path {
rect.height / 2 <= maxRadius
? Capsule().path(in: rect)
: RoundedRectangle(cornerRadius: maxRadius, style: .continuous).path(in: rect)
}
}
/// The field and its reveal bubble on one centre line. The field takes the width the bubble leaves it, and the
/// bubble is a circle as tall as the field, which is never shorter than `minimum` and grows with its text. Placed
/// left to right; SwiftUI mirrors a custom layout under a right-to-left layout itself.
private struct JoinedRow: Layout {
var spacing: CGFloat
var minimum: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let field = subviews.first else { return .zero }
let side = side(width: proposal.width, field: field)
let width = field.sizeThatFits(ProposedViewSize(width: proposal.width.map { max($0 - spacing - side, 0) }, height: side)).width
return CGSize(width: width + spacing + side, height: side)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count == 2 else { return }
let side = side(width: bounds.width, field: subviews[0])
subviews[0].place(at: CGPoint(x: bounds.minX, y: bounds.midY), anchor: .leading, proposal: ProposedViewSize(width: max(bounds.width - spacing - side, 0), height: side))
subviews[1].place(at: CGPoint(x: bounds.maxX, y: bounds.midY), anchor: .trailing, proposal: ProposedViewSize(width: side, height: side))
}
/// The field's height beside a bubble of that height, never below `minimum`.
private func side(width: CGFloat?, field: LayoutSubview) -> CGFloat {
func height(beside side: CGFloat) -> CGFloat {
max(field.sizeThatFits(ProposedViewSize(width: width.map { max($0 - spacing - side, 0) }, height: nil)).height, minimum)
}
let first = height(beside: minimum)
// A taller field means a wider bubble, so measure once more at the width that leaves.
return first > minimum ? height(beside: first) : first
}
}
/// Sets the Return key's label when one is given, and otherwise leaves whatever the field's surroundings set.
private struct ReturnKeyLabel: ViewModifier {
let label: SubmitLabel?
func body(content: Content) -> some View {
if let label { content.submitLabel(label) } else { content }
}
}
}
/// A house-palette color that follows the interface style.
private func adaptive(light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { @Sendable traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
// MARK: - Example
/// The field mid-typing with its strength gauge and requirement chips, and a second field in its error state. Return
/// in the first moves to the second.
private struct SecureEntryExample: View {
private enum Field: Hashable { case new, confirm }
@State private var password = "Juniper42"
@State private var confirm = "Juniper24!"
@FocusState private var focus: Field?
var body: some View {
VStack(alignment: .leading, spacing: 26) {
SecureEntry("New password", text: $password, submitLabel: .next, onSubmit: { focus = .confirm })
.focused($focus, equals: .new)
SecureEntry("Confirm password", text: $confirm, error: "Passwords do not match", showsStrength: false, showsRequirements: false, submitLabel: .done)
.focused($focus, equals: .confirm)
}
.padding(.horizontal, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(adaptive(light: 0xF3F2EE, dark: 0x121212))
}
}
#Preview("Light") {
SecureEntryExample()
}
#Preview("Dark") {
SecureEntryExample()
.preferredColorScheme(.dark)
}
// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, pressMath, shake)
/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
var reduceMotion = false
// Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
/// No overshoot, 90% in about 90ms. A press arriving under the finger.
static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
/// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
/// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
/// About 15% overshoot. A resolved action landing. At most once per interaction.
static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
/// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }
/// Reduce Motion: settles become this short spring with no overshoot.
private static var still: Animation { .spring(duration: 0.25, bounce: 0) }
// Roles.
/// Touch-down. Starts on the same frame and never bounces under the finger.
var press: Animation { .spring(Self.tight) }
/// The finger lifts off a pressed surface, which springs back through rest.
var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// Lands on a detent, page or segment.
var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
/// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
/// never shows a value that isn't true.
var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
/// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A shape or container changes size, corner radius or form, with a little give at the end.
var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
/// Something appears, opens or expands. Opening is a little slower than closing.
var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
/// Something leaves, closes or collapses. Quick and firm, out of the way.
var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
/// A resolved action lands: a check, a sent state, a reaction.
var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
/// A refused action. Firm, no wobble; `pieceShake` adds the movement.
var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
/// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }
/// Loops, drifts, idle breathing and parallax run only when this is true.
var allowsAmbient: Bool { !reduceMotion }
/// A moving transition, or a plain fade under Reduce Motion.
func transition(_ transition: AnyTransition) -> AnyTransition {
reduceMotion ? .opacity : transition
}
/// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
/// blurs out as the new one sharpens in. A fade under Reduce Motion.
@MainActor var swap: AnyTransition {
reduceMotion ? .opacity : AnyTransition(.blurReplace)
}
}
extension PieceMotion {
/// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
return min(max(1 - depth * 2 / side, 0.92), 0.985)
}
/// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
guard let touch, size.width > 0, size.height > 0 else { return .center }
let x = min(max(touch.x / size.width, 0), 1)
let y = min(max(touch.y / size.height, 0), 1)
return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
}
}
/// A short decaying side-to-side shake for refused input, each time `trigger` changes. Under Reduce Motion it stays
/// still (same view, no identity change): pair it with a color or message change and the error haptic.
private struct PieceShake: ViewModifier {
let trigger: AnyHashable
var distance: CGFloat = 8
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
let distance = reduceMotion ? 0 : distance
content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, x in
view.offset(x: x)
} keyframes: { _ in
KeyframeTrack {
CubicKeyframe(-distance, duration: 0.06)
CubicKeyframe(distance * 0.75, duration: 0.07)
CubicKeyframe(-distance * 0.5, duration: 0.07)
CubicKeyframe(distance * 0.25, duration: 0.06)
SpringKeyframe(0, duration: 0.12, spring: Spring(duration: 0.18, bounce: 0))
}
}
}
}
private extension View {
/// Shakes this view side to side once each time `trigger` changes, for input that was refused.
func pieceShake(trigger: some Hashable & Sendable, distance: CGFloat = 8) -> some View {
modifier(PieceShake(trigger: AnyHashable(trigger), distance: distance))
}
}
// swiftpieces-motion: end
// MARK: - Piece liquid
//
// The SwiftPieces liquid glass language: glass shapes that merge through a neck, bubbles that bud out of
// and melt back into each other, and the frosted fallback before iOS 26. Each piece carries a copy of only
// the parts it uses, so this file stands alone. Generated from registry/foundation/PieceLiquid.swift in the
// SwiftPieces repo; edit it there, not here. The rules are in LIQUID_GLASS.md.
// swiftpieces-liquid: 1.8.0 (liquid, liquidPress, liquidPop, bud, morphText)
/// The liquid glass language: one merge distance, two rest gaps, and the springs a bubble leaves and comes home on.
///
/// Glass shapes inside one `PieceLiquidGroup` melt into each other through a neck when they come within `merge`
/// points. Parts of one control rest `joined`, inside that distance, so the neck holds; separate actions rest
/// `apart`, outside it, so they only goo while one buds out of, or melts back into, another.
private enum PieceLiquid {
/// Glass shapes closer than this share a neck.
static let merge: CGFloat = 20
/// The gap between parts of one control (a stepper's buttons, a progress pill and its stop): the neck holds,
/// short and smooth, about two thirds of the shapes' height at its waist. Joined parts read best at one height.
static let joined: CGFloat = 4
/// The gap between separate actions (menu items, confirm and cancel, chips): they rest as their own bubbles.
static let apart: CGFloat = 26
/// How far a bubble shrinks while it is home inside its parent.
static let homeScale: CGFloat = 0.72
/// A bubble leaving its parent: slow enough that the neck's stretch and snap read.
static func split(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.62, bounce: 0.22)
}
/// A bubble going home. No bounce: a bounce would carry it out through the far side of its parent.
static func home(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.5, bounce: 0)
}
}
/// A group of glass shapes that merge into one liquid surface. On iOS 26 it is a `GlassEffectContainer`; before
/// that, and under Reduce Transparency, the shapes draw on their own and simply don't merge. `lift` adds the soft
/// shadow liquid glass floats on in light mode.
private struct PieceLiquidGroup<Content: View>: View {
var spacing: CGFloat = PieceLiquid.merge
var lift = true
@ViewBuilder var content: Content
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorScheme) private var colorScheme
var body: some View {
container
.shadow(color: .black.opacity(lift && colorScheme == .light ? 0.07 : 0), radius: 18, y: 8)
}
@ViewBuilder private var container: some View {
#if compiler(>=6.2)
if #available(iOS 26, *), !reduceTransparency {
GlassEffectContainer(spacing: spacing) { content }
} else {
content
}
#else
content
#endif
}
}
/// One liquid glass shape: Liquid Glass on iOS 26, carrying `tint` as a solid colour and swelling under a press when
/// `interactive`; a frosted Material with a light rim and a soft shadow before that; a solid fill under Reduce
/// Transparency.
///
/// The tint is painted inside clear glass rather than tinting the glass. Tinted glass in a group bleeds its colour
/// through every neck as a smear, so a red button would glow into the white pill it is joined to; painted inside,
/// the colour stays crisp to the shape's edge, the necks between shapes are clear glass, and a tint change animates
/// like any colour (tinted glass snaps).
private struct PieceLiquidSurface<S: Shape>: ViewModifier {
var shape: S
var tint: Color?
var interactive: Bool
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorScheme) private var colorScheme
@Environment(\.pieceLiquidScale) private var scale
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26, *), !reduceTransparency {
// Scaled as two parts, content and outline, about the same centre. A scaleEffect on a glass view inside a
// GlassEffectContainer shrinks the glass but leaves what it carries full size, pinned to its corner.
content
.background { shape.fill(tint ?? .clear) }
.scaleEffect(scale)
.glassEffect(glass, in: shape.scale(scale))
} else {
fallback(content).scaleEffect(scale)
}
#else
fallback(content).scaleEffect(scale)
#endif
}
#if compiler(>=6.2)
@available(iOS 26, *)
private var glass: Glass {
interactive ? Glass.regular.interactive() : .regular
}
#endif
private func fallback(_ content: Content) -> some View {
let dark = colorScheme == .dark
return content
.background {
if reduceTransparency {
shape.fill(tint ?? (dark ? Color(white: 0.17) : Color(white: 0.97)))
} else {
ZStack {
shape.fill(.regularMaterial)
if let tint { shape.fill(tint.opacity(0.88)) }
}
}
}
.overlay { shape.stroke(Color.white.opacity(dark ? 0.14 : 0.7), lineWidth: 0.5) }
.shadow(color: .black.opacity(dark ? 0.32 : 0.08), radius: 10, y: 5)
}
}
private struct PieceLiquidScaleKey: EnvironmentKey {
static let defaultValue: CGFloat = 1
}
private extension EnvironmentValues {
/// How much the liquid shapes below are scaled, about their own centres. Nested scales multiply.
var pieceLiquidScale: CGFloat {
get { self[PieceLiquidScaleKey.self] }
set { self[PieceLiquidScaleKey.self] = newValue }
}
}
private extension View {
/// Draws this view on a liquid glass `shape`. Put it inside a `PieceLiquidGroup` so it can merge with its neighbours.
func pieceLiquid<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = true) -> some View {
modifier(PieceLiquidSurface(shape: shape, tint: tint, interactive: interactive))
}
/// Scales the liquid glass shapes in this view, content and outline together, about their own centres. Use it
/// instead of `scaleEffect` on a glass view (a press, a lift, a swell): inside a group a plain `scaleEffect` shrinks
/// the glass but leaves its content full size and off centre. Animates like any other value.
func pieceLiquidScale(_ scale: CGFloat) -> some View {
transformEnvironment(\.pieceLiquidScale) { $0 *= scale }
}
}
/// The press for a glass control: the same size-aware sink and lean as `piecePress`, applied through
/// `pieceLiquidScale` so the glass and what it carries sink together (a plain scaleEffect on glass leaves the content
/// behind). Put the glass inside what it presses: the label of a button, the view this modifies. Under Reduce Motion
/// it shades instead of moving.
private struct PieceLiquidPress: ViewModifier {
let pressed: Bool
var touch: CGPoint?
var depth: CGFloat = 2.5
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
@State private var size: CGSize = .zero
@State private var anchor: UnitPoint = .center
func body(content: Content) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
// A scale about `anchor` is a scale about the centre plus this shift toward the anchor.
let lean = CGSize(width: (anchor.x - 0.5) * size.width * (1 - scale), height: (anchor.y - 0.5) * size.height * (1 - scale))
content
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
.pieceLiquidScale(scale)
.offset(lean)
.animation(pressed ? motion.press : motion.release, value: pressed)
.onChange(of: pressed) { _, isPressed in
if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
.onChange(of: touch) { _, touch in
if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
}
}
/// `PiecePressStyle` for glass buttons: the label (with its `.pieceLiquid` inside) sinks as one.
private struct PieceLiquidPressStyle: ButtonStyle {
var depth: CGFloat = 2.5
func makeBody(configuration: Configuration) -> some View {
configuration.label.pieceLiquidPress(configuration.isPressed, depth: depth)
}
}
private extension View {
/// Sinks this view's glass while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
func pieceLiquidPress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
modifier(PieceLiquidPress(pressed: pressed, touch: touch, depth: depth))
}
}
/// `piecePop` for glass: dips, swells past full size and lands each time `trigger` changes, on the same timings,
/// through `pieceLiquidScale` so the glass and what it carries pop together. Driven from state rather than a
/// keyframe animator, since the liquid scale can't be set from inside one. Still under Reduce Motion.
private struct PieceLiquidPop: ViewModifier {
let trigger: AnyHashable
var amount: CGFloat = 0.08
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var scale: CGFloat = 1
@State private var run = 0
func body(content: Content) -> some View {
content
.pieceLiquidScale(scale)
.onChange(of: trigger) { _, _ in
guard !reduceMotion else { return }
run += 1
let mine = run
withAnimation(.easeInOut(duration: 0.08)) { scale = 1 - amount * 0.4 }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(80))
guard run == mine else { return }
withAnimation(.spring(duration: 0.18, bounce: 0)) { scale = 1 + amount }
try? await Task.sleep(for: .milliseconds(140))
guard run == mine else { return }
withAnimation(.spring(PieceMotion.expressive)) { scale = 1 }
}
}
}
}
private extension View {
/// Pops this view's glass each time `trigger` changes. Use a counter, never a Bool that can flip back before it fires.
func pieceLiquidPop(trigger: some Hashable & Sendable, amount: CGFloat = 0.08) -> some View {
modifier(PieceLiquidPop(trigger: AnyHashable(trigger), amount: amount))
}
}
/// The bud: how a bubble leaves and rejoins its parent, driven explicitly so every bubble shows the whole cycle.
///
/// A bubble is born at `home`, inside its parent, where the two glass shapes are one. It springs out to `rest`, and
/// while it is inside the merge distance a neck holds it to the parent, thinning as it goes, until it snaps free.
/// Going home it springs back on a spring with no bounce, the neck reaches out and re-forms, and only once it has
/// melted all the way in is it removed. Both offsets are relative to where the bubble is laid out. Under Reduce
/// Motion it stays at `rest`: its content fades and its glass closes in place.
private struct PieceBud: ViewModifier {
var out: Bool
var rest: CGSize
var home: CGSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
if reduceMotion {
// No travel. Inside a group glass ignores opacity, so the glass closes to nothing in place on the short
// Reduce Motion ease while its content fades; the opacity covers a bubble outside a group.
content
.pieceLiquidScale(out ? 1 : 0.001)
.opacity(out ? 1 : 0)
.offset(rest)
} else {
// The glass shrinks through `pieceLiquidScale`, never a plain scaleEffect (see there), then moves.
content
.pieceLiquidScale(out ? 1 : PieceLiquid.homeScale)
.offset(out ? rest : home)
}
}
}
/// A bubble's own content, on its own clock: gone the moment the bubble heads home, so it never rides over the
/// parent's content, and arriving just after the bubble leaves.
private struct PieceBudContent: ViewModifier {
var out: Bool
func body(content: Content) -> some View {
content
.blur(radius: out ? 0 : 6)
.opacity(out ? 1 : 0)
.animation(out ? .easeOut(duration: 0.3).delay(0.1) : .easeOut(duration: 0.14), value: out)
}
}
private extension View {
/// Places a bubble at `rest` while `out`, and at `home` (inside its parent, shrunk) while not.
func pieceBud(out: Bool, rest: CGSize = .zero, home: CGSize) -> some View {
modifier(PieceBud(out: out, rest: rest, home: home))
}
/// Hides a bubble's icon or label while it is home. Put it on the content, inside the glass.
func pieceBudContent(out: Bool) -> some View {
modifier(PieceBudContent(out: out))
}
}
/// Which bubbles exist and which are out. A bubble is added home with no animation, sent out on the next frame,
/// and called home before it is removed, so it always melts in rather than fading. Keep one in `@State`.
@MainActor @Observable
private final class PieceBuds {
private(set) var present: [String] = []
private(set) var out: Set<String> = []
/// The latest call for each bubble. A bloom or gather that has been overtaken (a bubble sent home while it was
/// still waiting to go out, or called out again while melting) leaves that bubble alone.
@ObservationIgnored private var turn: [String: Int] = [:]
func contains(_ id: String) -> Bool { present.contains(id) }
func isOut(_ id: String) -> Bool { out.contains(id) }
private func claim(_ ids: [String]) -> [String: Int] {
var mine: [String: Int] = [:]
for id in ids {
let next = (turn[id] ?? 0) + 1
turn[id] = next
mine[id] = next
}
return mine
}
/// Puts bubbles straight out at rest with no motion: a view's first frame, or a state restored.
func place(_ ids: [String]) {
_ = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
out.formUnion(ids)
}
}
/// Adds bubbles home, then sends each out, `stagger` seconds apart, after an optional `delay`.
func bloom(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.05, delay: Double = 0) async {
let mine = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
}
try? await Task.sleep(for: .milliseconds(24 + Int(max(delay, 0) * 1000)))
let split = PieceLiquid.split(reduceMotion: reduceMotion)
for (i, id) in ids.enumerated() where turn[id] == mine[id] {
withAnimation(split.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.insert(id) }
}
}
/// Calls bubbles home, last first, then removes them once they have melted in.
func gather(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.04) async {
let mine = claim(ids)
let home = PieceLiquid.home(reduceMotion: reduceMotion)
for (i, id) in ids.reversed().enumerated() {
withAnimation(home.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.remove(id) }
}
try? await Task.sleep(for: .milliseconds(Int((0.52 + Double(ids.count) * stagger) * 1000)))
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) { present.removeAll { ids.contains($0) && !out.contains($0) && turn[$0] == mine[$0] } }
}
}
/// A label that changes letter by letter: letters both strings share hold still, the rest blur out and the new ones
/// blur in a few milliseconds apart. Under Reduce Motion it cross-fades. VoiceOver reads the whole string.
///
/// Only text whose letters stand on their own is cut into letters: Latin, Greek, Cyrillic, CJK, digits and symbols.
/// Those letters are laid out left to right whatever the layout direction, the order they read in, so "1.5×" stays
/// "1.5×" in an Arabic or Hebrew interface instead of coming out backwards. Text in a right-to-left script, or in one
/// whose letters join or reorder (Arabic, Hebrew, Devanagari, Thai and their neighbours), can't be cut apart without
/// breaking it, so it changes as one: the old string blurs out where it stood while the new one blurs in.
private struct PieceMorphText: View {
var text: String
var font: Font = .body.weight(.semibold)
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
// The morphing label at its full size when it fits. When it doesn't (a long label at an accessibility text
// size, a narrow screen), the same words wrap onto a second line and shrink a little instead of running past
// the screen's edge; that one changes as a whole rather than letter by letter. A single word never splits
// across lines: it stays on one and shrinks further.
let oneWord = !text.contains(where: \.isWhitespace)
ViewThatFits(in: .horizontal) {
morphing.fixedSize()
Text(text)
.lineLimit(oneWord ? 1 : 2)
.multilineTextAlignment(.center)
.minimumScaleFactor(oneWord ? 0.5 : 0.75)
.fixedSize(horizontal: false, vertical: true)
.id(text)
.transition(transition(0, scale: 0.9))
}
.font(font)
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
}
@ViewBuilder private var morphing: some View {
if Self.splits(text) {
let glyphs = Array(text)
HStack(spacing: 0) {
ForEach(glyphs.indices, id: \.self) { i in
Text(String(glyphs[i]))
.id("\(i)\(glyphs[i])")
.transition(transition(i, scale: 0.6))
}
}
// The letters' reading order, not the layout's: an HStack lays its children out backwards under a
// right-to-left layout.
.environment(\.layoutDirection, .leftToRight)
} else {
ZStack(alignment: .leading) {
Text(text)
.id(text)
.transition(transition(0, scale: 0.9))
}
}
}
private func transition(_ i: Int, scale: CGFloat) -> AnyTransition {
guard !reduceMotion else { return .opacity }
return AnyTransition(.blurReplace(.downUp)).combined(with: .scale(scale: scale, anchor: .bottom))
.animation(.spring(duration: 0.42, bounce: 0.3).delay(Double(i) * 0.022))
}
/// Whether `text` can be cut into letters that each read left to right on their own: no right-to-left script, no
/// script that joins or reorders its letters, and no bidirectional control characters.
private static func splits(_ text: String) -> Bool {
!text.unicodeScalars.contains { scalar in
switch scalar.value {
case 0x0590...0x08FF, // Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic
0x0900...0x0DFF, // Devanagari to Sinhala
0x0E00...0x0FFF, // Thai, Lao, Tibetan
0x1000...0x109F, // Myanmar
0x1780...0x17FF, // Khmer
0xA8E0...0xA8FF, // Devanagari Extended
0xFB1D...0xFDFF, // Hebrew and Arabic presentation forms
0xFE70...0xFEFF, // Arabic presentation forms B
0x200E...0x200F, 0x202A...0x202E, 0x2066...0x2069, // bidirectional marks, embeddings and isolates
0x10800...0x10FFF, // historic right-to-left scripts
0x1E800...0x1EFFF: // Adlam and other right-to-left scripts
return true
default:
return false
}
}
}
}
// swiftpieces-liquid: end- Type
- SecureEntry
- Files
- SecureEntry.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.3.0
Liquid glass password field with strength meter, live requirements, and inline error. The field is a glass shape with a round glass reveal bubble joined to it. Everything below it buds out of the field when it appears and melts back into it when it goes: the error chip, joined just under it, and, apart from it, the strength chip and the requirement chips.
Notes
- The field is liquid glass, with a round glass reveal bubble joined to its end by a liquid neck. Revealing swaps
SecureFieldforTextFieldinstantly; the toggle re-targets@FocusStateto the new field so the keyboard stays up, and its eye glyph blurs into the other. The bubble sinks under the finger. - Focus draws a 2pt ring around the field. The field's glass carries its state:
Style.error(butter) while there is an error,Style.success(sage) once every requirement passes, neutral otherwise. Text on a tinted field turns to dark ink. - Everything below the field lines up with it and buds straight down out of it when it appears, then melts back into it when it goes, while the room it takes opens and closes on the same springs, so whatever sits below moves with it instead of jumping.
- Setting
errorfromnilshakes the field once with an.errorhaptic on the same beat, tints it butter, and buds a butter error chip out of its bottom edge to rest joined just under it. A new message for an error already showing blurs into the new wording in place, with no second shake or haptic, so live validation never buzzes per keystroke. To signal a later refusal, seterrortonilwhen the user edits, so the next refused submit arrives fromniland shakes again. Clearing it melts the chip back into the field as the tint drains. VoiceOver hears each new or reworded message once, as an announcement. - The strength chip buds out of the field as typing starts and rests apart below it, beside a gauge that fades in. Its label morphs letter by letter from WEAK through FAIR and GOOD to STRONG, and the chip and gauge take the level's tint: butter while weak or fair, sage once good. The gauge changes length without overshoot, so the reading never wobbles.
- Requirement chips bud out of the field while it is focused or filled, two to a row, resting apart. A met requirement clicks into sage glass with a small dip and swell, its check drawing in after the tint lands, with a
.selectionhaptic. When all four pass, the field turns sage and a check blurs into it with a.successhaptic in place of the tick and the pop of the last chip, and the chips stay a beat so the last one lands, then melt back into the field. Leaving the field melts them at once, and losing a rule during that beat keeps them. submitLabelandonSubmitset the Return key and what it does, the same whether the password is hidden or revealed. LeavesubmitLabelasnilto keep a label set around the field. To chain fields, apply.focused($focus, equals: .confirm)to eachSecureEntry(focus reaches the text field inside) and move focus inonSubmit.Stylesets every tint plus the field's minimum height and its radius. The field grows past that height when larger text needs it, and the reveal bubble grows with it, a circle as tall as the field on the same centre line. At accessibility text sizes the strength chip moves under its gauge, the requirement chips stack one to a row and wrap long labels, and a chip whose text wraps takes rounded corners in place of capsule ends. All text is semibold..disabled(true)dims the field.- Reduce Motion: no shake and no press scale (the reveal bubble shades instead), the chips open and close where they rest instead of budding, the morphing label and the swapped glyphs crossfade, and met chips change tint without the pop. The tints, the drawn checks and every haptic remain. Reduce Transparency draws the glass as solid shapes.
- For one-time codes after sign-in, see the Verify screen (code entry) in SwiftPieces Pro.
Usage
SecureEntryExample()Parameters
| Parameter | Description |
|---|---|
label | Static caption above the field, also the placeholder and accessibility label. |
text | Bound password text. |
error | Validation message. Setting it from nil shakes the field once with an error haptic, tints it with Style.error, buds an error chip out of its bottom edge, and announces the message to VoiceOver. A new message for a standing error swaps in place and is announced, without a second shake; clear it to nil on edit so the next refused submit shakes again. |
showsStrength | Show the strength gauge and its label chip once text is non-empty. |
showsRequirements | Show the 8+ / number / symbol / mixed-case chips while focused or filled. When all four pass the field takes the success tint with a check, and the chips melt back into it a beat later. |
submitLabel | The Return key label, e.g. .go or .next, hidden or revealed. nil, the default, keeps the label set around the field, .return unless one is. |
onSubmit | Called when Return is pressed, hidden or revealed. Submit the form or move focus to the next field here. |
style | Colors and field metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. |
Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first
Building a whole app? See SwiftPieces Pro →
More SwiftUI inputs and forms
All inputs and forms →Build it yourself: SwiftUI haptics guide