Move compound-ios package into the project.

This commit is contained in:
Doug
2025-09-28 15:19:46 +01:00
committed by Doug
parent 9874d6fd84
commit ed73853c9b
80 changed files with 3658 additions and 25 deletions
@@ -0,0 +1,70 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
struct ListInlinePicker<SelectedValue: Hashable>: View {
let title: String?
@Binding var selection: SelectedValue
let items: [(title: String, tag: SelectedValue)]
let isWaiting: Bool
var body: some View {
ForEach(items, id: \.tag) { item in
ListRow(label: .plain(title: item.title),
details: isWaiting ? .isWaiting(selection == item.tag) : nil,
kind: .selection(isSelected: !isWaiting ? selection == item.tag : false) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
selection = item.tag
}
})
}
}
}
// MARK: - Previews
struct ListInlinePicker_Previews: PreviewProvider, TestablePreview {
static var previews: some View { Preview() }
struct Preview: View {
@State var selection = "Item 1"
let items = ["Item 1", "Item 2", "Item 3"]
var body: some View {
Form {
Section("Compound") {
ListInlinePicker(title: "Title",
selection: $selection,
items: items.map { (title: $0, tag: $0) },
isWaiting: false)
}
Section("Compound with loader") {
ListInlinePicker(title: "Title",
selection: $selection,
items: items.map { (title: $0, tag: $0) },
isWaiting: true)
}
Section("Native") {
Picker("", selection: $selection) {
ForEach(items, id: \.self) { item in
Text(item)
.tag(item)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
}
}
@@ -0,0 +1,476 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
public enum ListRowPadding {
public static let horizontal: CGFloat = 16
public static let vertical: CGFloat = 13
public static let insets = EdgeInsets(top: vertical,
leading: horizontal,
bottom: vertical,
trailing: horizontal)
public static let textFieldInsets = EdgeInsets(top: 11,
leading: horizontal,
bottom: 11,
trailing: horizontal)
}
public struct ListRow<Icon: View, DetailsIcon: View, CustomContent: View, SelectionValue: Hashable>: View {
@Environment(\.isEnabled) private var isEnabled
let label: ListRowLabel<Icon>
let details: ListRowDetails<DetailsIcon>?
public enum Kind<CustomContent: View, SelectionValue: Hashable> {
case label
case button(action: () -> Void)
case navigationLink(action: () -> Void)
case picker(selection: Binding<SelectionValue>, items: [(title: String, tag: SelectionValue)])
case toggle(Binding<Bool>)
case inlinePicker(selection: Binding<SelectionValue>, items: [(title: String, tag: SelectionValue)])
case selection(isSelected: Bool, action: () -> Void)
case multiSelection(isSelected: Bool, action: () -> Void)
case textField(text: Binding<String>, axis: Axis?)
case secureField(text: Binding<String>)
case custom(() -> CustomContent)
public static func textField(text: Binding<String>) -> Self {
.textField(text: text, axis: nil)
}
}
let kind: Kind<CustomContent, SelectionValue>
public var body: some View {
rowContent
.buttonStyle(ListRowButtonStyle())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.compound.bgCanvasDefaultLevel1)
.listRowSeparatorTint(.compound._borderInteractiveSecondaryAlpha)
}
@ViewBuilder
var rowContent: some View {
switch kind {
case .label:
RowContent(details: details) { label }
case .button(let action):
Button(action: action) {
RowContent(details: details) { label }
}
case .navigationLink(let action):
Button(action: action) {
RowContent(details: details, accessory: .navigationLink) { label }
}
case .picker(let selection, let items):
HStack(spacing: 0) {
label
Spacer()
// Note: VoiceOver label already provided.
Picker("", selection: selection) {
ForEach(items, id: \.tag) { item in
Text(item.title)
.tag(item.tag)
}
}
.labelsHidden()
.padding(.vertical, -10)
.padding(.trailing, ListRowPadding.horizontal)
}
.accessibilityElement(children: .combine)
case .toggle(let binding):
HStack(spacing: 0) {
label
Spacer()
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
if let details {
ListRowTrailingSection(details)
}
// Note: VoiceOver label already provided.
Toggle("", isOn: binding)
.toggleStyle(.compound)
.labelsHidden()
.padding(.vertical, -10)
}
}
.padding(.trailing, ListRowPadding.horizontal)
.accessibilityElement(children: .combine)
case .inlinePicker(let selection, let items):
ListInlinePicker(title: label.title ?? "",
selection: selection,
items: items,
isWaiting: details?.isWaiting ?? false)
case .selection(let isSelected, let action):
Button(action: action) {
RowContent(details: details, accessory: .selection(isSelected)) { label }
}
.isToggle()
case .multiSelection(let isSelected, let action):
Button(action: action) {
RowContent(details: details, accessory: .multiSelection(isSelected)) { label }
}
.isToggle()
case .textField(let text, let axis):
TextField(text: text, axis: axis) {
Text(label.title ?? "")
.compoundTextFieldPlaceholder()
}
.tint(.compound.iconAccentTertiary)
.foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled)
.listRowInsets(ListRowPadding.textFieldInsets)
case .secureField(let text):
SecureField(text: text) {
Text(label.title ?? "")
.compoundTextFieldPlaceholder()
}
.tint(.compound.iconAccentTertiary)
.foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled)
.listRowInsets(ListRowPadding.textFieldInsets)
case .custom(let content):
content()
}
}
}
// MARK: - Initialisers
// Normal row with a details icon
public extension ListRow where CustomContent == EmptyView {
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) {
self.label = label
self.details = details
self.kind = kind
}
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = label
self.details = details
self.kind = kind
}
}
// Normal row without a details icon.
public extension ListRow where DetailsIcon == EmptyView, CustomContent == EmptyView {
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) {
self.label = label
self.details = details
self.kind = kind
}
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = label
self.details = details
self.kind = kind
}
}
// Custom row without a label or details label.
public extension ListRow where Icon == EmptyView, DetailsIcon == EmptyView {
init(kind: Kind<CustomContent, SelectionValue>) {
self.label = ListRowLabel()
self.details = nil
self.kind = kind
}
init(kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = ListRowLabel()
self.details = nil
self.kind = kind
}
}
/// The standard content for labels, and button based rows.
///
/// This doesn't use `LabeledContent` as that will happily build using an `EmptyView`
/// in the content. This creates an issue where the label ends up hidden to VoiceOver,
/// presumably because SwiftUI thinks that the row doesn't contain any content.
private struct RowContent<Label: View, DetailsIcon: View>: View {
let details: ListRowDetails<DetailsIcon>?
var accessory: ListRowAccessory?
let label: () -> Label
var body: some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
label()
.frame(maxWidth: .infinity)
if details != nil || accessory != nil {
ListRowTrailingSection(details, accessory: accessory)
}
}
.frame(maxHeight: .infinity)
.padding(.trailing, ListRowPadding.horizontal)
.accessibilityElement(children: .combine)
}
}
// MARK: - Helpers
private extension TextField {
/// Creates a text field with an optional preferred axis. Hard coding a default resulted
/// in the underlying component always being a `UITextView` during introspection.
/// This initialiser does the right thing when not supplying an axis in `ListRow.Kind`.
init(text: Binding<String>, axis: Axis?, label: () -> Label) {
if let axis {
self.init(text: text, axis: axis, label: label)
} else {
self.init(text: text, label: label)
}
}
}
private extension Button {
/// Adds the `isToggle` accessibility trait on iOS 17+
@ViewBuilder func isToggle() -> some View {
if #available(iOS 17.0, *) {
accessibilityAddTraits(.isToggle)
} else {
self
}
}
}
// MARK: - Previews
public struct ListRow_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
Form {
Section {
labels
buttons
pickers
toggles
selection
actionButtons
plainButton
}
centeredActionButtonSections
descriptionLabelSection
avatarSection
othersSection
}
.compoundList()
.frame(idealHeight: 2100) // Snapshot height
.previewLayout(.sizeThatFits)
}
static var labels: some View {
ListRow(label: .default(title: "Label",
description: "Non-interactive item",
systemIcon: .squareDashed),
details: .label(title: "Content",
systemIcon: .squareDashed,
isWaiting: true),
kind: .label)
}
@ViewBuilder static var buttons: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .default(title: "Destructive",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I will delete things!") })
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
details: .label(title: "Details", systemIcon: .squareDashed),
kind: .navigationLink { print("Perform navigation!") })
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
kind: .navigationLink { print("Perform navigation!") })
}
@ViewBuilder static var pickers: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .picker(selection: .constant(0),
items: [(title: "Item 1", tag: 0),
(title: "Item 2", tag: 1),
(title: "Item 3", tag: 2)]))
ListRow(label: .default(title: "Very very very very very very long title",
description: "Description…",
systemIcon: .squareDashed),
kind: .picker(selection: .constant(0),
items: [(title: "Item 1", tag: 0),
(title: "Item 2", tag: 1),
(title: "Item 3", tag: 2)]))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
kind: .picker(selection: .constant("Item 1"),
items: [(title: "Item 1", tag: "Item 1"),
(title: "Item 2", tag: "Item 2"),
(title: "Item 3", tag: "Item 3")]))
}
@ViewBuilder static var toggles: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Very very very very very very very long title",
description: "Description…",
systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
details: .isWaiting(true),
kind: .toggle(.constant(false)))
}
@ViewBuilder static var selection: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
details: .title("Content"),
kind: .selection(isSelected: true) {
print("I was tapped!")
})
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
details: .title("Content"),
kind: .selection(isSelected: true) {
print("I was tapped!")
})
ListRow(label: .plain(title: "Title"),
kind: .inlinePicker(selection: .constant("Item 1"),
items: [(title: "Item 1", tag: "Item 1"),
(title: "Item 2", tag: "Item 2"),
(title: "Item 3", tag: "Item 3")]))
}
@ViewBuilder static var actionButtons: some View {
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I was tapped!") })
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
.disabled(true)
}
static var plainButton: some View {
ListRow(label: .plain(title: "Title"),
kind: .button { print("I was tapped!") })
}
@ViewBuilder static var centeredActionButtonSections: some View {
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
}
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I was tapped!") })
}
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
.disabled(true)
}
}
static var descriptionLabelSection: some View {
Section {
ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."),
kind: .label)
}
}
static var avatarSection: some View {
Section {
ListRow(label: .avatar(title: "Alice",
description: "@alice:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background)),
kind: .multiSelection(isSelected: true) { })
ListRow(label: .avatar(title: "Bob",
description: "@bob:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[1].background)),
kind: .multiSelection(isSelected: false) { })
ListRow(label: .avatar(title: "Dan",
status: "Pending",
description: "@dan:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[3].background)),
kind: .multiSelection(isSelected: false) { })
.disabled(true)
ListRow(label: .avatar(title: "@charlie:fake.com",
description: "This user can't be found, so the invite may not be received.",
icon: Circle().foregroundStyle(.compound.decorativeColors[2].background),
role: .error),
kind: .button { })
}
}
@ViewBuilder static var othersSection: some View {
Section {
ListRow(kind: .custom {
Text("This is a custom row")
.padding(.horizontal, 16)
.padding(.vertical, 20)
})
ListRow(label: .plain(title: "Placeholder"),
kind: .textField(text: .constant("This is a disabled text field")))
.disabled(true)
ListRow(label: .plain(title: "Placeholder"),
kind: .textField(text: .constant(""), axis: .vertical))
.lineLimit(4...)
ListRow(label: .plain(title: "Password"),
kind: .secureField(text: .constant("p4ssw0rd")))
}
}
}
struct ListRowLoadingSelection_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
ListRow(label: .plain(title: "Selected",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(false),
kind: .selection(isSelected: true) { })
ListRow(label: .plain(title: "Unselected",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(false),
kind: .selection(isSelected: false) { })
ListRow(label: .plain(title: "Unselected & Loading",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(true),
kind: .selection(isSelected: false) { })
}
.compoundList()
}
}
@@ -0,0 +1,112 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
/// A view to be added on the trailing edge of a form row.
public struct ListRowAccessory: View {
@Environment(\.isEnabled) private var isEnabled
enum Kind {
/// A navigation chevron.
case navigationLink
/// A checkmark.
case selected
/// ``selected`` but invisible to reserve space.
case unselected
/// A circular checkmark.
case multiSelected
/// An empty circle.
case multiUnselected
}
/// A chevron to indicate that the button pushes another screen.
public static var navigationLink: Self {
Self.init(kind: .navigationLink)
}
/// A checkmark (or reserved space) to indicate that the row is selected.
public static func selection(_ isSelected: Bool) -> Self {
Self.init(kind: isSelected ? .selected : .unselected)
}
/// A circular checkmark (or empty circle) to indicate that the row is one of multiple selected.
public static func multiSelection(_ isSelected: Bool) -> Self {
Self.init(kind: isSelected ? .multiSelected : .multiUnselected)
}
let kind: Kind
/// Negative padding added to prevent the accessory interfering with the row's padding.
private var verticalPaddingFix: CGFloat { -4 }
/// Absolute bodge until we have the circle icon in Compound.
@ScaledMetric private var circleOverlayInsets = 5
public var body: some View {
switch kind {
case .navigationLink:
CompoundIcon(\.chevronRight)
.foregroundColor(.compound.iconTertiaryAlpha)
.flipsForRightToLeftLayoutDirection(true)
case .selected:
CompoundIcon(\.check)
.foregroundColor(isEnabled ? .compound.iconAccentPrimary : .compound.iconDisabled)
.accessibilityAddTraits(.isSelected)
.padding(.vertical, verticalPaddingFix)
case .unselected:
CompoundIcon(\.check)
.hidden()
.padding(.vertical, verticalPaddingFix)
case .multiSelected:
CompoundIcon(\.checkCircleSolid)
.foregroundColor(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled)
.accessibilityAddTraits(.isSelected)
.padding(.vertical, verticalPaddingFix)
case .multiUnselected:
CompoundIcon(\.circle)
.foregroundColor(isEnabled ? .compound.borderInteractivePrimary : .compound.borderDisabled)
.padding(.vertical, verticalPaddingFix)
}
}
}
// MARK: - Previews
struct ListRowAccessory_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Grid(alignment: .leading, verticalSpacing: 16) {
row(title: "Navigation Link", accessory: .navigationLink)
row(title: "Navigation Link disabled", accessory: .navigationLink)
.disabled(true)
row(title: "Selected", accessory: .selection(true))
row(title: "Selected disabled", accessory: .selection(true))
.disabled(true)
row(title: "Unselected", accessory: .selection(false))
row(title: "Unselected disabled", accessory: .selection(false))
.disabled(true)
row(title: "Multi-selected", accessory: .multiSelection(true))
row(title: "Multi-selected disabled", accessory: .multiSelection(true))
.disabled(true)
row(title: "Multi-unselected", accessory: .multiSelection(false))
row(title: "Multi-unselected disabled", accessory: .multiSelection(false))
.disabled(true)
}
.previewDisplayName("Accessories")
}
static func row(title: String, accessory: ListRowAccessory) -> some View {
GridRow {
accessory
Text(title)
.foregroundStyle(.compound.textSecondary)
}
}
}
@@ -0,0 +1,65 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
// TODO: Check if the primitive style is actually needed now the insets are part of ListRow.
// It might still be useful for ListRow(kind: .custom) usage?
/// Default button styling for list rows.
///
/// The primitive style is needed to set the list row insets to `0`. The inner style is then needed
/// to change the background colour depending on whether the button is currently pressed or not.
public struct ListRowButtonStyle: PrimitiveButtonStyle {
public func makeBody(configuration: Configuration) -> some View {
Button(role: configuration.role, action: configuration.trigger) {
configuration.label
}
.buttonStyle(Style())
}
/// Inner style used to set the pressed background colour.
struct Style: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.contentShape(Rectangle())
.background(configuration.isPressed ? Color.compound.bgSubtlePrimary : .compound.bgCanvasDefaultLevel1)
}
}
}
// MARK: - Previews
// TODO: Fix the previews, either the style should expand the label to fill or
// the previews need to do this manually for demonstration purposes.
public struct ListRowButtonStyle_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
Form {
Section {
Button("Title") { }
.buttonStyle(ListRowButtonStyle.Style())
}
.listRowInsets(EdgeInsets())
Section {
Button("Title") { }
Button("Title") { }
Button("Title") { }
}
.buttonStyle(ListRowButtonStyle())
.listRowInsets(EdgeInsets())
Section {
ShareLink(item: "test")
.buttonStyle(ListRowButtonStyle())
}
.listRowInsets(EdgeInsets())
}
.compoundList()
}
}
@@ -0,0 +1,94 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import CompoundDesignTokens
import SFSafeSymbols
import SwiftUI
/// The configuration of the details portion of a list row's trailing section.
/// This consists of the title, icon and a waiting indicator.
public struct ListRowDetails<Icon: View> {
var title: String?
var icon: Icon?
var counter: Int?
var isWaiting = false
// MARK: - Initialisers
public static func label(title: String,
icon: Icon,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(title: title,
icon: icon,
counter: counter,
isWaiting: isWaiting)
}
public static func label(title: String,
icon: KeyPath<CompoundIcons, Image>,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == CompoundIcon {
ListRowDetails(title: title,
icon: CompoundIcon(icon),
counter: counter,
isWaiting: isWaiting)
}
public static func label(title: String,
systemIcon: SFSymbol,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == Image {
ListRowDetails(title: title,
icon: Image(systemSymbol: systemIcon),
counter: counter,
isWaiting: isWaiting)
}
public static func icon(_ icon: Icon,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(icon: icon,
counter: counter,
isWaiting: isWaiting)
}
public static func icon(_ icon: KeyPath<CompoundIcons, Image>,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == CompoundIcon {
ListRowDetails(icon:CompoundIcon(icon),
counter: counter,
isWaiting: isWaiting)
}
public static func systemIcon(_ systemIcon: SFSymbol,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == Image {
ListRowDetails(icon: Image(systemSymbol: systemIcon),
counter: counter,
isWaiting: isWaiting)
}
}
public extension ListRowDetails where Icon == Image {
static func title(_ title: String,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(title: title,
counter: counter,
isWaiting: isWaiting)
}
static func counter(_ counter: Int, isWaiting: Bool = false) -> Self {
ListRowDetails(counter: counter, isWaiting: isWaiting)
}
static func isWaiting(_ isWaiting: Bool) -> Self {
ListRowDetails(isWaiting: isWaiting)
}
}
@@ -0,0 +1,373 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import CompoundDesignTokens
import SFSafeSymbols
import SwiftUI
/// The main label style used in the leading section of `ListRow`.
struct ListRowLabelStyle: LabelStyle {
let iconAlignment: VerticalAlignment
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: iconAlignment, spacing: 16) {
configuration.icon
configuration.title
}
}
}
/// The label style used in `ListRow` for centred buttons.
struct ListRowCenteredLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 8) {
configuration.icon
configuration.title
}
}
}
/// The label style used in the leading section of `ListRow` that show an avatar as the icon.
///
/// Unlike the other styles, this one sizes the avatar internally.
struct ListRowAvatarLabelStyle: LabelStyle {
@ScaledMetric private var avatarSize = 32.0
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 16) {
configuration.icon
.frame(width: avatarSize, height: avatarSize)
.padding(.vertical, -5) // Don't allow the avatar to size the row.
configuration.title
}
}
}
public struct ListRowLabel<Icon: View>: View {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.lineLimit) private var lineLimit
@ScaledMetric private var iconSize = 30.0
var title: String?
var status: String?
var description: String?
var icon: Icon?
var role: Role?
public enum Role {
/// A role that indicates a destructive action.
case destructive
/// A role that indicates an error.
///
/// The label should contain a description when using this role.
case error
}
var iconAlignment: VerticalAlignment = .center
var hideIconBackground: Bool = false
enum Layout { case `default`, centered, avatar }
var layout: Layout = .default
var titleColor: Color {
guard isEnabled else { return .compound.textDisabled }
return role == .destructive ? .compound.textCriticalPrimary : .compound.textPrimary
}
var titleLineLimit: Int? { layout == .avatar ? 1 : lineLimit }
var statusColor: Color {
isEnabled ? .compound.textSecondary : .compound.textDisabled
}
var descriptionColor: Color {
isEnabled ? .compound.textSecondary : .compound.textDisabled
}
var descriptionLineLimit: Int? {
guard layout == .avatar else { return lineLimit }
return role != .error ? 1 : lineLimit
}
var iconForegroundColor: Color {
guard isEnabled else { return .compound.iconTertiaryAlpha }
if role == .destructive { return .compound.textCriticalPrimary }
return hideIconBackground ? .compound.iconPrimary : .compound.iconTertiaryAlpha
}
var iconBackgroundColor: Color {
if hideIconBackground { return .clear }
guard isEnabled else { return .compound._bgSubtleSecondaryAlpha }
return role == .destructive ? .compound._bgCriticalSubtleAlpha : .compound._bgSubtleSecondaryAlpha
}
public var body: some View {
Group {
switch layout {
case .default:
defaultBody
case .centered:
centeredBody
case .avatar:
avatarBody
}
}
.padding(.leading, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
}
var defaultBody: some View {
Label {
titleAndDescription
} icon: {
icon
.foregroundColor(iconForegroundColor)
.frame(width: iconSize, height: iconSize)
.background(iconBackgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.vertical, -4) // Don't allow the background to size the row.
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.labelStyle(ListRowLabelStyle(iconAlignment: iconAlignment))
}
var centeredBody: some View {
Label {
if let title {
Text(title)
.font(.compound.bodyLG)
.foregroundColor(titleColor)
}
} icon: {
icon
.foregroundColor(iconForegroundColor)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.labelStyle(ListRowCenteredLabelStyle())
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
}
var avatarBody: some View {
Label {
titleAndDescription
} icon: {
icon // Layout handled by the style.
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.labelStyle(ListRowAvatarLabelStyle())
}
var titleAndDescription: some View {
VStack(alignment: .leading, spacing: 2) {
if let title {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(title)
.font(.compound.bodyLG)
.foregroundColor(titleColor)
.lineLimit(titleLineLimit)
// Status is only available in the avatar init which requires a title,
// so no need to worry about the outer `if let` being nil in this instance.
if let status {
Text(status)
.font(.compound.bodySM)
.foregroundColor(statusColor)
.lineLimit(1)
}
}
}
if let description {
HStack(alignment: .top, spacing: 4) {
if role == .error {
CompoundIcon(\.errorSolid, size: .xSmall, relativeTo: .compound.bodySM)
.foregroundStyle(.compound.iconCriticalPrimary)
}
Text(description)
.font(.compound.bodySM)
.foregroundColor(descriptionColor)
.lineLimit(descriptionLineLimit)
}
}
}
.accessibilityElement(children: .combine)
}
// MARK: - Initialisers
public static func `default`(title: String,
description: String? = nil,
icon: Icon,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel {
ListRowLabel(title: title,
description: description,
icon: icon,
role: role,
iconAlignment: iconAlignment)
}
public static func `default`(title: String,
description: String? = nil,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == CompoundIcon {
.default(title: title,
description: description,
icon: CompoundIcon(icon),
role: role,
iconAlignment: iconAlignment)
}
public static func `default`(title: String,
description: String? = nil,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == Image {
.default(title: title,
description: description,
icon: Image(systemSymbol: systemIcon),
role: role,
iconAlignment: iconAlignment)
}
public static func action(title: String,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
icon: icon,
role: role,
hideIconBackground: true)
}
public static func action(title: String,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon {
.action(title: title, icon: CompoundIcon(icon), role: role)
}
public static func action(title: String,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image {
.action(title: title, icon: Image(systemSymbol: systemIcon), role: role)
}
public static func centeredAction(title: String,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
icon: icon,
role: role,
hideIconBackground: true,
layout: .centered)
}
public static func centeredAction(title: String,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon {
.centeredAction(title: title, icon: CompoundIcon(icon), role: role)
}
public static func centeredAction(title: String,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image {
.centeredAction(title: title, icon: Image(systemSymbol: systemIcon), role: role)
}
public static func plain(title: String,
description: String? = nil,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == EmptyView {
ListRowLabel(title: title, description: description, role: role, hideIconBackground: true)
}
public static func description(_ description: String) -> ListRowLabel where Icon == EmptyView {
ListRowLabel(description: description)
}
/// A label that displays an avatar as it's icon, such as a user profile row or for a room picker.
public static func avatar(title: String,
status: String? = nil,
description: String? = nil,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
status: status,
description: description,
icon: icon,
role: role,
layout: .avatar)
}
}
// MARK: - Previews
struct ListRowLabel_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
Section {
Group {
ListRowLabel.default(title: "Person", icon: Image(systemName: "person"))
ListRowLabel.default(title: "Help",
description: "Supporting text",
systemIcon: .questionmarkCircle)
ListRowLabel.default(title: "Trash",
icon: Image(systemName: "trash"),
role: .destructive)
}
Group {
ListRowLabel.action(title: "Camera",
icon: Image(systemName: "camera"))
ListRowLabel.action(title: "Remove",
icon: Image(systemName: "person.badge.minus"),
role: .destructive)
ListRowLabel.centeredAction(title: "Person",
icon: Image(systemName: "person"))
ListRowLabel.centeredAction(title: "Remove",
systemIcon: .personBadgeMinus,
role: .destructive)
}
Group {
ListRowLabel.plain(title: "Person")
ListRowLabel.plain(title: "Remove",
role: .destructive)
ListRowLabel.plain(title: "Plain", description: "Description")
}
ListRowLabel.description("This is a row in the list, that only contains a description and doesn't have either an icon or a title.")
}
.listRowInsets(EdgeInsets())
Section {
ListRowLabel.avatar(title: "Alice",
description: "@alice:example.com",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background))
ListRowLabel.avatar(title: "Alice",
status: "Pending",
description: "@alice:example.com",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background))
ListRowLabel.avatar(title: "@bob:idontexist.com",
description: "This user can't be found, so the invite may not be received.",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background),
role: .error)
}
.listRowInsets(EdgeInsets())
Section {
ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."),
kind: .label)
}
}
.compoundList()
.frame(idealHeight: 1000) // Snapshot height
.previewLayout(.sizeThatFits)
}
}
@@ -0,0 +1,133 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
/// The spacing used inside of a ListRow
enum ListRowTrailingSectionSpacing {
static let horizontal = 8.0
}
/// The style applied to the details label in a list row's trailing section.
private struct ListRowDetailsLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
configuration.title
.foregroundColor(.compound.textSecondary)
configuration.icon
.foregroundColor(.compound.iconPrimary)
}
.font(.compound.bodyLG)
}
}
/// The view shown to the right of the `ListRowLabel` inside of a `ListRow`.
/// This consists of both the `ListRowDetails` and the `ListRowAccessory`.
public struct ListRowTrailingSection<Icon: View>: View {
@Environment(\.isEnabled) private var isEnabled
private var title: String?
private var icon: Icon?
private var counter: Int?
private var isWaiting = false
private var accessory: ListRowAccessory?
@ScaledMetric private var iconSize = 24
private var hideAccessory: Bool { isWaiting && accessory?.kind == .unselected }
init(_ details: ListRowDetails<Icon>?, accessory: ListRowAccessory? = nil) {
title = details?.title
icon = details?.icon
isWaiting = details?.isWaiting ?? false
counter = details?.counter
self.accessory = accessory
}
public var body: some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
if isWaiting {
ProgressView()
}
if title != nil || icon != nil {
Label {
title.map(Text.init)
} icon: {
icon
}
.labelStyle(ListRowDetailsLabelStyle())
}
if let counter {
Text("\(counter)")
.font(.compound.bodyLG)
.foregroundStyle(.compound.textOnSolidPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background { Capsule().fill(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled) }
}
if let accessory, !hideAccessory {
accessory
}
}
.frame(minWidth: iconSize)
.accessibilityElement(children: .combine)
}
}
// MARK: - Previews
struct ListRowTrailingSection_Previews: PreviewProvider, TestablePreview {
static let someCondition = true
static let otherCondition = true
static var previews: some View {
VStack(spacing: 40) {
details
withAccessory
}
}
static var details: some View {
VStack(spacing: 20) {
ListRowTrailingSection(.label(title: "Content", icon: Image(systemName: "square.dashed")))
ListRowTrailingSection(.label(title: "Content", systemIcon: .squareDashed))
ListRowTrailingSection(.title("Content"))
ListRowTrailingSection(.icon(Image(systemName: "square.dashed")))
ListRowTrailingSection(.systemIcon(.squareDashed))
ListRowTrailingSection(.isWaiting(true))
ListRowTrailingSection(.systemIcon(.checkmark))
ListRowTrailingSection(.title("Hello"))
ListRowTrailingSection(someCondition ? .isWaiting(true) : otherCondition ? .systemIcon(.checkmark) : .title("Hello"))
ListRowTrailingSection(.title("Hello", counter: 1))
ListRowTrailingSection(.title("Hello", counter: 1))
.disabled(true)
}
}
static var withAccessory: some View {
VStack(spacing: 20) {
ListRowTrailingSection(.isWaiting(true), accessory: .selection(true))
.border(.purple)
// The checkmark should be hidden.
ListRowTrailingSection(.isWaiting(true), accessory: .selection(false))
.border(.purple)
// The checkmark's space should be reserved.
ListRowTrailingSection(.isWaiting(false), accessory: .selection(false))
.border(.purple)
ListRowTrailingSection(.counter(1), accessory: .navigationLink)
.border(.purple)
}
}
}
@@ -0,0 +1,88 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
public extension View {
/// Styles a list using the Compound design tokens.
func compoundList() -> some View {
environment(\.defaultMinListRowHeight, 48)
.scrollContentBackground(.hidden)
.background(Color.compound.bgSubtleSecondaryLevel0.ignoresSafeArea())
}
/// Styles a list section header using the Compound design tokens.
func compoundListSectionHeader() -> some View {
font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.listRowInsets(EdgeInsets(top: 15,
leading: ListRowPadding.horizontal,
bottom: 8,
trailing: ListRowPadding.horizontal))
}
/// Styles a list section footer using the Compound design tokens.
func compoundListSectionFooter() -> some View {
font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.listRowInsets(EdgeInsets(top: 8,
leading: ListRowPadding.horizontal,
bottom: 10,
trailing: ListRowPadding.horizontal))
}
}
struct ListTextStyles_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
Section {
ListRow(label: .plain(title: "Hi!"), kind: .label)
} footer: {
Text("This is a footer down ere")
.compoundListSectionFooter()
}
Section {
ListRow(label: .plain(title: "Second!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "Third!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "I was slow, I'm last."), kind: .label)
} footer: {
Text("This is a footer down ere")
.compoundListSectionFooter()
}
}
.compoundList()
.previewDisplayName("Form")
List {
Section {
ListRow(label: .plain(title: "Hello"), kind: .label)
ListRow(label: .plain(title: "World!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
} footer: {
Text("Section footer")
.compoundListSectionFooter()
}
}
.compoundList()
.previewDisplayName("List")
}
}