Files
letro-ios/compound-ios/Sources/Compound/List/ListRow.swift
2025-11-28 13:27:45 +01:00

503 lines
20 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 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 ListRowColor {
public static var separatorTint: Color {
if #available(iOS 26, *) {
.compound.bgSubtleSecondary
} else {
.compound._borderInteractiveSecondaryAlpha
}
}
}
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<CustomView: View, Selection: Hashable> {
case label
case button(action: () -> Void)
case navigationLink(action: () -> Void)
case picker(selection: Binding<Selection>, items: [(title: String, tag: Selection)])
case toggle(Binding<Bool>)
case inlinePicker(selection: Binding<Selection>, items: [(title: String, tag: Selection)])
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(() -> CustomView)
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(ListRowColor.separatorTint)
}
@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>) {
label = ListRowLabel()
details = nil
self.kind = kind
}
init(kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
label = ListRowLabel()
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 {
// If not custom, the label() content usually includes a leading `ListRowPadding.horizontal`
// that's why the external `HStack` has 0 spacing.
HStack(spacing: 0) {
// We should always have multi selection shown on the leading side
if let accessory, accessory.kind.isMultiSelection {
accessory
.padding(.leading, ListRowPadding.horizontal)
}
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
label()
.frame(maxWidth: .infinity)
if details != nil || accessory != nil {
ListRowTrailingSection(details,
// Prevent multi selection to appear on the trailing side
accessory: accessory?.kind.isMultiSelection == true ? nil : 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
// swiftlint:disable print_deprecation
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: 2275) // 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()
}
}
// swiftlint:enable print_deprecation