Instantly share code, notes, and snippets.
Created
February 11, 2025 16:30
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
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.
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: - 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