Task Row
A task card whose check draws itself while the card flashes into a solid color block, a priority label block, a swipe-right that completes with a strike-through then compacts the row, swipe-left snooze and delete blocks, and a long-press lift that reports reorder moves.
- Type
- TaskRow
- Files
- TaskRow.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Activity
Account activity grouped by day, with rows that swipe to categorize, split or hide.
Interactive task row for to-do lists, driven by a bound Status.
Notes
- Rows are soft cards (
style.surface, 22pt radius). Priority shows as a small HIGH, MED or LOW label block (tangerine, butter, sky) with dark ink; due dates carry a clock glyph; a snoozed task gets a lilac Snoozed label and a lilac check. - Completion is a sequence, not a toggle: the card flashes into the solid
tintblock (sage by default) while the check draws in with a trimmed path and a.successhaptic, the strike-through grows across the title, then the row settles back and compacts (smaller padding and type, due label and priority fold away). Settingstatusto.completedfrom outside runs the same sequence; rows that load already completed start settled..openreverses it. Reduce Motion crossfades every step at once. - Swipe right to the 104pt detent to complete (or reopen a completed row). The complete block's glyph grows with the reveal, the drag rubber-bands past the detent, and a rigid impact marks the moment it arms. Swipe left reveals Snooze (lilac) and Delete (signal) blocks, only for the callbacks you pass. Blocks and the check dip when pressed.
- Long-press (0.35s) lifts the row with a soft impact, scale 1.03 and a shadow; drag it vertically and
onMovereceives the number of row slots moved on release (negative is up). The gesture is only installed whenonMoveis set. .disabled(true)dims the row to 50% and turns off the swipe and lift.Style(TaskRow.Style):surface,text,muted,ink,complete,snooze,delete,high,medium,low,cornerRadius.tint:still overrides the complete color.- Stack rows with 8pt spacing on the ground. VoiceOver reads the row as one button with status, due date and priority, and exposes Complete/Reopen, Snooze, Delete and Move up/down as custom actions.
- Need grouped-by-day activity with split and expanding detail? That is the Transaction Row screen in Swift Pieces Pro.
Usage
TaskRowExample().preferredColorScheme(.light)Parameters
| Parameter | Description |
|---|---|
title | Task text. |
status | .open, .completed, or .snoozed. Set it from outside and the row animates to match. |
due | Optional secondary label, such as "Today, 5 PM". |
priority | Optional priority label block at the trailing edge. |
tint | Block color for the check, the completion flash and the complete tile. nil uses style.complete. |
style | Card surface, text colors, priority and tile blocks, and corner radius. Defaults to the house palette. |
onTap | Called when the row body is tapped, typically to open the task. |
onSnooze | Adds a Snooze block behind a left swipe and is called when it is chosen. |
onDelete | Adds a Delete block behind a left swipe and is called when it is chosen. |
onMove | Enables long-press lift and reports how many rows the user dragged it (negative is up) on release. |
Source
import SwiftUI
import UIKit
/// Interactive task row for to-do lists, driven by a bound `Status`.
///
/// - Parameters:
/// - title: Task text.
/// - status: `.open`, `.completed`, or `.snoozed`. Set it from outside and the row animates to match.
/// - due: Optional secondary label, such as "Today, 5 PM".
/// - priority: Optional priority label block at the trailing edge.
/// - tint: Block color for the check, the completion flash and the complete tile. `nil` uses `style.complete`.
/// - style: Card surface, text colors, priority and tile blocks, and corner radius. Defaults to the house palette.
/// - onTap: Called when the row body is tapped, typically to open the task.
/// - onSnooze: Adds a Snooze block behind a left swipe and is called when it is chosen.
/// - onDelete: Adds a Delete block behind a left swipe and is called when it is chosen.
/// - onMove: Enables long-press lift and reports how many rows the user dragged it (negative is up) on release.
public struct TaskRow: View {
public enum Status: Equatable { case open, completed, snoozed }
public enum Priority { case low, medium, high }
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
@Binding private var status: Status
@State private var offset: CGFloat = 0
@State private var dragStart: CGFloat? = nil
@State private var armed = false
@State private var check: CGFloat = 0
@State private var strike: CGFloat = 0
@State private var compact = false
@State private var lifted = false
@State private var lift: CGFloat = 0
@State private var rowHeight: CGFloat = 72
@State private var completions = 0
private let title: String
private let due: String?
private let priority: Priority?
private let tint: Color
private let style: Style
private let onTap: (() -> Void)?
private let onSnooze: (() -> Void)?
private let onDelete: (() -> Void)?
private let onMove: ((Int) -> Void)?
private let tileWidth: CGFloat = 76
private let tileGap: CGFloat = 6
private let completeThreshold: CGFloat = 104
public init(_ title: String, status: Binding<Status>, due: String? = nil, priority: Priority? = nil, tint: Color? = nil, style: Style = .standard, onTap: (() -> Void)? = nil, onSnooze: (() -> Void)? = nil, onDelete: (() -> Void)? = nil, onMove: ((Int) -> Void)? = nil) {
self.title = title
self._status = status
self.due = due
self.priority = priority
self.tint = tint ?? style.complete
self.style = style
self.onTap = onTap
self.onSnooze = onSnooze
self.onDelete = onDelete
self.onMove = onMove
}
private var completing: Bool { status == .completed && !compact }
private var spring: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.18) }
private var cardShape: RoundedRectangle { RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) }
public var body: some View {
card
.scaleEffect(lifted ? 1.03 : 1)
.shadow(color: .black.opacity(lifted ? 0.2 : 0), radius: 22, y: 12)
.offset(x: offset, y: lift)
.zIndex(lifted ? 1 : 0)
.background(alignment: .leading) { completeTile }
.background(alignment: .trailing) { trailingTiles }
.opacity(isEnabled ? 1 : 0.5)
.contentShape(.rect)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { rowHeight = $0 }
.gesture(swipe, including: isEnabled ? .all : .subviews)
.highPriorityGesture(liftGesture, including: onMove == nil || !isEnabled ? .none : .all)
.animation(.spring(duration: 0.35, bounce: 0.2), value: lifted)
.onChange(of: status, initial: true) { old, new in apply(new, from: old) }
.sensoryFeedback(.impact(flexibility: .rigid), trigger: armed) { _, isArmed in isArmed }
.sensoryFeedback(.impact(flexibility: .soft), trigger: lifted) { _, isLifted in isLifted }
.sensoryFeedback(.success, trigger: completions)
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue(accessibilityValue)
.accessibilityAddTraits(.isButton)
.accessibilityAction { onTap?() }
.accessibilityActions {
Button(status == .completed ? "Reopen" : "Complete") { toggle() }
if let onSnooze { Button("Snooze") { status = .snoozed; onSnooze() } }
if let onDelete { Button("Delete", role: .destructive, action: onDelete) }
if let onMove {
Button("Move up") { onMove(-1) }
Button("Move down") { onMove(1) }
}
}
}
// MARK: Card
private var card: some View {
HStack(spacing: 14) {
checkControl
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(compact ? .subheadline.weight(.medium) : .body.weight(.semibold))
.foregroundStyle(completing ? style.ink : status == .open ? style.text : style.muted)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.overlay(alignment: .leading) {
// The strike draws from the leading edge over the text's own width.
Capsule()
.fill(completing ? style.ink : style.muted)
.frame(height: 2)
.scaleEffect(x: strike, y: 1, anchor: .leading)
.opacity(strike > 0 ? 1 : 0)
}
if !compact, status == .snoozed || due != nil {
meta
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
if let priority, !compact, status == .open {
priorityLabel(priority)
.transition(.scale(scale: 0.6).combined(with: .opacity))
}
}
.padding(.leading, 12)
.padding(.trailing, 16)
.padding(.vertical, compact ? 8 : 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
ZStack {
style.surface
// The card becomes the solid block while the completion runs, then settles back as it compacts.
tint.opacity(completing ? 1 : 0)
}
}
.clipShape(cardShape)
.contentShape(cardShape)
.onTapGesture { if offset != 0 { settle(0) } else { onTap?() } }
}
@ViewBuilder
private var meta: some View {
if status == .snoozed {
Label("Snoozed", systemImage: "moon.zzz.fill")
.font(.caption.weight(.bold))
.foregroundStyle(style.ink)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(style.snooze, in: Capsule())
} else if let due {
Label(due, systemImage: "clock")
.font(.footnote.weight(.medium))
.foregroundStyle(completing ? style.ink.opacity(0.7) : style.muted)
.labelStyle(TightLabel())
}
}
private func priorityLabel(_ priority: Priority) -> some View {
let (label, color): (String, Color) = switch priority {
case .low: ("LOW", style.low)
case .medium: ("MED", style.medium)
case .high: ("HIGH", style.high)
}
return Text(label)
.font(.caption2.weight(.heavy))
.tracking(0.8)
.foregroundStyle(style.ink)
.padding(.horizontal, 8)
.frame(minHeight: 22)
.background(color, in: Capsule())
.fixedSize()
.accessibilityHidden(true)
}
private var checkControl: some View {
Button(action: toggle) {
ZStack {
Circle()
.strokeBorder(status == .open ? style.muted.opacity(0.55) : .clear, lineWidth: 2)
Circle()
.fill(status == .snoozed ? style.snooze : completing ? style.ink : tint)
.scaleEffect(status == .open ? 0.001 : 1)
if status == .snoozed {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(style.ink)
.transition(.scale.combined(with: .opacity))
} else {
checkmark
.trim(from: 0, to: check)
.stroke(completing ? tint : style.ink, style: StrokeStyle(lineWidth: 2.6, lineCap: .round, lineJoin: .round))
}
}
.frame(width: compact ? 24 : 28, height: compact ? 24 : 28)
.frame(width: 44, height: 44)
.contentShape(.rect)
.animation(spring, value: status)
}
.buttonStyle(PressStyle(reduceMotion: reduceMotion))
}
private var checkmark: Path {
Path { path in
let s = compact ? 24.0 : 28.0
path.move(to: CGPoint(x: s * 0.29, y: s * 0.52))
path.addLine(to: CGPoint(x: s * 0.44, y: s * 0.67))
path.addLine(to: CGPoint(x: s * 0.72, y: s * 0.37))
}
}
private var accessibilityValue: String {
var parts: [String] = []
switch status {
case .open: parts.append("Not completed")
case .completed: parts.append("Completed")
case .snoozed: parts.append("Snoozed")
}
if let due, status != .snoozed { parts.append("Due \(due)") }
if let priority, status == .open {
switch priority {
case .low: parts.append("Low priority")
case .medium: parts.append("Medium priority")
case .high: parts.append("High priority")
}
}
return parts.joined(separator: ", ")
}
// MARK: Tiles
@ViewBuilder
private var completeTile: some View {
let revealed = max(offset - tileGap, 0)
if revealed > 0 {
let reveal = min(1, revealed / completeThreshold)
Image(systemName: status == .completed ? "arrow.uturn.backward" : "checkmark")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(style.ink)
.scaleEffect(0.55 + 0.45 * reveal + (armed ? 0.2 : 0))
.frame(width: revealed, height: rowHeight)
.background(tint, in: RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous))
.animation(.snappy(duration: 0.2), value: armed)
.accessibilityHidden(true)
}
}
@ViewBuilder
private var trailingTiles: some View {
let revealed = max(-offset, 0)
if revealed > 0, trailingCount > 0 {
let reveal = min(1, revealed / trailingWidth)
HStack(spacing: tileGap) {
if let onSnooze {
tile("Snooze", systemImage: "moon.zzz", color: style.snooze, reveal: reveal) { status = .snoozed; onSnooze() }
}
if let onDelete {
tile("Delete", systemImage: "trash", color: style.delete, reveal: reveal, action: onDelete)
}
}
.padding(.leading, tileGap)
.frame(width: revealed, alignment: .leading)
.clipped()
.accessibilityHidden(true)
}
}
private func tile(_ label: String, systemImage: String, color: Color, reveal: CGFloat, action: @escaping () -> Void) -> some View {
Button { settle(0); action() } label: {
VStack(spacing: 5) {
Image(systemName: systemImage)
.font(.system(size: 19, weight: .semibold))
.scaleEffect(0.55 + 0.45 * reveal)
Text(label)
.font(.caption.weight(.semibold))
.opacity(max(0, (reveal - 0.45) / 0.55))
}
.foregroundStyle(style.ink)
.frame(width: tileWidth - tileGap, height: rowHeight)
.background(color, in: RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous))
}
.buttonStyle(PressStyle(reduceMotion: reduceMotion))
}
private var trailingCount: Int { (onSnooze == nil ? 0 : 1) + (onDelete == nil ? 0 : 1) }
private var trailingWidth: CGFloat { tileWidth * CGFloat(trailingCount) }
// MARK: Gestures
private var swipe: some Gesture {
DragGesture(minimumDistance: 12)
.onChanged { value in
guard !lifted else { return }
if dragStart == nil {
guard abs(value.translation.width) > abs(value.translation.height) else { return }
dragStart = offset
}
guard let dragStart else { return }
let x = dragStart + value.translation.width
if x > 0 {
// Past the threshold the row eases toward a limit so the commit point feels like a detent.
offset = x < completeThreshold ? x : completeThreshold + rubber(x - completeThreshold)
} else {
offset = trailingCount == 0 ? -rubber(-x) : max(x, -trailingWidth - rubber(-x - trailingWidth))
}
let nowArmed = offset >= completeThreshold
if nowArmed != armed { armed = nowArmed }
}
.onEnded { value in
guard let start = dragStart else { return }
dragStart = nil
if armed {
armed = false
settle(0)
toggle()
return
}
let projected = start + value.predictedEndTranslation.width
settle(projected < -trailingWidth / 2 && trailingCount > 0 ? -trailingWidth : 0)
}
}
private var liftGesture: some Gesture {
LongPressGesture(minimumDuration: 0.35)
.sequenced(before: DragGesture(minimumDistance: 0))
.onChanged { value in
guard case .second(true, let drag) = value else { return }
if !lifted { lifted = true }
lift = drag?.translation.height ?? 0
}
.onEnded { _ in
let steps = Int((lift / (rowHeight + 8)).rounded())
withAnimation(spring) { lift = 0 }
lifted = false
if steps != 0 { onMove?(steps) }
}
}
private func rubber(_ distance: CGFloat) -> CGFloat { 40 * (1 - 1 / (1 + distance / 40)) }
private func settle(_ target: CGFloat) {
withAnimation(spring) { offset = target }
}
// MARK: State
private func toggle() {
status = status == .completed ? .open : .completed
}
private func apply(_ new: Status, from old: Status) {
switch new {
case .completed:
if old != .completed { completions += 1 }
if old == new {
// Rows that load already completed start settled, with no flash.
check = 1; strike = 1; compact = true
} else if reduceMotion {
withAnimation(.easeOut(duration: 0.25)) { check = 1; strike = 1; compact = true }
} else {
withAnimation(.easeOut(duration: 0.3)) { check = 1 }
withAnimation(.easeInOut(duration: 0.35).delay(0.15)) { strike = 1 }
withAnimation(.spring(duration: 0.45, bounce: 0.12).delay(0.7)) { compact = true }
}
case .open, .snoozed:
withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.1)) {
check = 0
strike = 0
compact = false
}
}
}
private struct PressStyle: ButtonStyle {
let reduceMotion: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed && !reduceMotion ? 0.88 : 1)
.animation(.spring(duration: 0.3, bounce: 0.3), value: configuration.isPressed)
}
}
private struct TightLabel: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon.imageScale(.small)
configuration.title
}
}
}
}
public extension TaskRow {
/// Look of a `TaskRow`. Start from `.standard` and change what you need.
struct Style: Sendable {
/// Card fill. Adapts to light and dark.
public var surface: Color = Style.adaptive(0xFFFFFF, 0x1C1C1C)
/// Title color for open tasks.
public var text: Color = Style.adaptive(0x141414, 0xF4F3EF)
/// Due label, strike-through and finished titles.
public var muted: Color = Style.adaptive(0x5C5A56, 0xA6A49F)
/// Dark ink used on every block.
public var ink: Color = Color(red: 0.078, green: 0.078, blue: 0.078)
/// Check fill, completion wash and complete tile, unless `tint` is passed.
public var complete: Color = Color(red: 0.663, green: 0.863, blue: 0.718)
/// Snooze tile, snoozed check and label.
public var snooze: Color = Color(red: 0.804, green: 0.722, blue: 1)
/// Delete tile.
public var delete: Color = Color(red: 1, green: 0, blue: 0)
/// Priority label blocks.
public var high: Color = Color(red: 1, green: 0, blue: 0)
public var medium: Color = Color(red: 1, green: 0.851, blue: 0.463)
public var low: Color = Color(red: 0.612, green: 0.761, blue: 1)
/// Corner radius of the card and its tiles.
public var cornerRadius: CGFloat = 22
public init() {}
/// The house palette: white or charcoal cards, sage check, lilac snooze, signal delete.
public static let standard = Style()
private static func adaptive(_ light: UInt32, _ dark: UInt32) -> Color {
Color(UIColor { 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
/// Four task cards and nothing else: open, completed, snoozed, with priority blocks.
private struct TaskRowExample: View {
@State private var tasks: [(String, String?, TaskRow.Priority?, TaskRow.Status)] = [
("Review the launch checklist", "Today, 5 PM", .high, .open),
("Send Mara the final mockups", "Today, 6 PM", .medium, .open),
("Book the studio for Thursday", "Tomorrow", nil, .snoozed),
("Renew the domain", nil, .low, .completed),
]
var body: some View {
VStack(spacing: 8) {
ForEach(tasks.indices, id: \.self) { index in
TaskRow(tasks[index].0, status: $tasks[index].3, due: tasks[index].1, priority: tasks[index].2, onSnooze: {}, onDelete: {}, onMove: { _ in })
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.071, green: 0.071, blue: 0.071, alpha: 1) : UIColor(red: 0.953, green: 0.949, blue: 0.933, alpha: 1) }))
}
}
#Preview("Light") {
TaskRowExample().preferredColorScheme(.light)
}
#Preview("Dark") {
TaskRowExample().preferredColorScheme(.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.
Building a whole app? See Swift Pieces Pro →
Swipe Action Row
Wraps any row in a soft card with swipe actions drawn as solid color blocks. Tiles grow in as the row opens, the drag rubber-bands, a full swipe arms with a haptic and lets the edge block take the whole bar, and a shared binding keeps one row open at a time.
Floating Dock
A floating dock on a solid surface where the selected item becomes a color block with its label, a drag across the dock lifts each item under the finger with a name bubble and commits on release, badges count in a signal pill, and the whole dock tucks away on scroll.