mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Move compound-ios package into the project.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user