Skip to content

Instantly share code, notes, and snippets.

@groue
Created February 11, 2025 16:30
Show Gist options
  • Save groue/a174651c199e3e9d7e6b1377ce0e05b1 to your computer and use it in GitHub Desktop.
Save groue/a174651c199e3e9d7e6b1377ce0e05b1 to your computer and use it in GitHub Desktop.
A SwiftUI button that looks like a category button in the Reminders app.
import SwiftUI
// MARK: - CategoryButton
/// A button that looks like a category button in the Reminders app.
///
/// ╭─────────────╮
/// │ (•) 12 │
/// │ │
/// │ Title │
/// ╰─────────────╯
/// ╭─────────────╮
/// │ (•) │
/// │ │
/// │ Title │
/// ╰─────────────╯
struct CategoryButton<Title: View, Icon: View>: View {
var label: Label<Title, Icon>
var badge: Int?
var action: @MainActor () -> Void
init(
badge: Int? = nil,
action: @escaping @MainActor () -> Void,
@ViewBuilder label: () -> Label<Title, Icon>
) {
self.badge = badge
self.label = label()
self.action = action
}
var body: some View {
Button(action: action) {
CategoryButtonLabel(label: label, badge: badge)
}
.buttonStyle(CategoryButtonStyle())
}
}
extension CategoryButton {
init<ImageStyle: ShapeStyle>(
_ titleKey: LocalizedStringKey,
systemImage: String,
imageStyle: ImageStyle,
badge: Int? = nil,
action: @escaping @MainActor () -> Void
)
where Title == Text, Icon == CategoryIcon<Image, ImageStyle>
{
self.init(badge: badge) {
action()
} label: {
Label {
Text(titleKey)
} icon: {
CategoryIcon(
image: Image(systemName: systemImage),
imageStyle: imageStyle)
}
}
}
}
// MARK: - CategoryLink
/// A link that looks like a category button in the Reminders app.
///
/// ╭─────────────╮
/// │ (•) 12 │
/// │ │
/// │ Title │
/// ╰─────────────╯
/// ╭─────────────╮
/// │ (•) │
/// │ │
/// │ Title │
/// ╰─────────────╯
struct CategoryLink<Title: View, Icon: View>: View {
var label: Label<Title, Icon>
var badge: Int?
var destination: URL
init(
destination: URL,
badge: Int? = nil,
@ViewBuilder label: () -> Label<Title, Icon>
) {
self.badge = badge
self.label = label()
self.destination = destination
}
var body: some View {
Link(destination: destination) {
CategoryButtonLabel(label: label, badge: badge)
}
.buttonStyle(CategoryButtonStyle())
}
}
extension CategoryLink {
init<ImageStyle: ShapeStyle>(
_ titleKey: LocalizedStringKey,
systemImage: String,
imageStyle: ImageStyle,
badge: Int? = nil,
destination: URL
)
where Title == Text, Icon == CategoryIcon<Image, ImageStyle>
{
self.init(destination: destination, badge: badge) {
Label {
Text(titleKey)
} icon: {
CategoryIcon(
image: Image(systemName: systemImage),
imageStyle: imageStyle)
}
}
}
}
// MARK: - CategorySection
/// A section that can group multiple `CategoryButton`.
///
/// ╭─────────────╮ ╭─────────────╮
/// │ (•) 12 │ │ (•) 3 │
/// │ │ │ │
/// │ Cats │ │ Dogs │
/// ╰─────────────╯ ╰─────────────╯
/// ╭─────────────╮
/// │ (•) 0 │
/// │ │
/// │ Pets │
/// ╰─────────────╯
struct CategorySection<Content: View>: View {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
Section {
content
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
extension CGFloat {
/// The recommended spacing between category buttons.
///
/// Note that in Reminders app, this spacing does not depend on
/// dynamic type.
static let categoryButtonSpacing: Self = 14
}
// MARK: - Implementation
struct CategoryIcon<Image: View, ImageStyle: ShapeStyle>: View {
var image: Image
var imageStyle: ImageStyle
var body: some View {
image.foregroundStyle(imageStyle)
}
}
private struct CategoryButtonLabel<Title: View, Icon: View>: View {
var label: Label<Title, Icon>
var badge: Int?
var body: some View {
VStack(alignment: .leading) {
HStack {
label.labelStyle(CategoryIconStyle())
badgeText
.lineLimit(1)
.font(.largeTitle)
.bold()
}
label.labelStyle(CategoryTitleStyle())
}
}
private var badgeText: Text {
if let badge {
Text(badge, format: .number)
} else {
// Always displayed, so that spacing between image and title
// does not depend on the presence of the badge.
Text(verbatim: " ")
}
}
}
private struct CategoryIconStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
configuration
.icon
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct CategoryTitleStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
configuration
.title
.lineLimit(1)
.font(.headline)
.foregroundStyle(.secondary)
}
}
private struct CategoryButtonStyle: ButtonStyle {
@ScaledMetric(relativeTo: .body) private var topPadding = 8
@ScaledMetric(relativeTo: .body) private var bottomPadding = 10
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity)
.padding(.top, topPadding)
.padding(.bottom, bottomPadding)
.padding(.horizontal)
.background {
if configuration.isPressed {
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.gray.tertiary)
} else {
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.background.tertiary)
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("CategoryButton") {
NavigationStack {
List {
CategorySection {
VStack(spacing: .categoryButtonSpacing) {
HStack(spacing: .categoryButtonSpacing) {
CategoryButton(badge: 1) { } label: {
Label {
Text(verbatim: "Pupils")
} icon: {
Image(systemName: "person.circle.fill")
.foregroundStyle(.orange)
}
}
CategoryButton(badge: 12) { } label: {
Label {
Text(verbatim: "Projects")
} icon: {
Image(systemName: "film.circle.fill")
.foregroundStyle(.blue)
}
}
}
HStack(spacing: .categoryButtonSpacing) {
CategoryButton { } label: {
Label {
Text(verbatim: "Archive")
} icon: {
Image(systemName: "archivebox.circle.fill")
.foregroundStyle(.gray)
}
}
Spacer().frame(maxWidth: .infinity)
}
}
}
Text(verbatim: "Alignment Test")
}
.listStyle(.insetGrouped)
}
}
#Preview("CategoryLink") {
NavigationStack {
List {
CategorySection {
VStack(spacing: .categoryButtonSpacing) {
HStack(spacing: .categoryButtonSpacing) {
CategoryLink(
destination: URL(string: "https://example.com")!,
badge: 1) {
Label {
Text(verbatim: "Pupils")
} icon: {
Image(systemName: "person.circle.fill")
.foregroundStyle(.orange)
}
}
CategoryLink(
destination: URL(string: "https://example.com")!,
badge: 12) {
Label {
Text(verbatim: "Projects")
} icon: {
Image(systemName: "film.circle.fill")
.foregroundStyle(.blue)
}
}
}
HStack(spacing: .categoryButtonSpacing) {
CategoryLink(destination: URL(string: "https://example.com")!) {
Label {
Text(verbatim: "Archive")
} icon: {
Image(systemName: "archivebox.circle.fill")
.foregroundStyle(.gray)
}
}
Spacer().frame(maxWidth: .infinity)
}
}
}
Text(verbatim: "Alignment Test")
}
.listStyle(.insetGrouped)
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment