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,312 @@
//
// Copyright 2022-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 ButtonStyle where Self == CompoundButtonStyle {
/// A button style that applies Compound design tokens to a button with various configuration options.
/// - Parameter kind: The kind of button being shown such as primary or secondary.
/// - Parameter size: The button size to use. Defaults to `large`.
static func compound(_ kind: Self.Kind, size: Self.Size = .large) -> CompoundButtonStyle {
CompoundButtonStyle(kind: kind, size: size)
}
}
/// Default button style for standalone buttons.
public struct CompoundButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityShowButtonShapes) private var accessibilityShowButtonShapes
var kind: Kind
public enum Kind {
/// A stroked button that uses colour to highlight important actions.
case `super`
/// A filled button usually representing the default action.
case primary
/// A stroked button usually representing alternate actions.
case secondary
/// A plain button with matching dimensions to ``primary`` and ``secondary``.
case tertiary
/// A plain button with no padding.
case textLink
}
var size: Size
public enum Size {
/// A button that is prominently sized.
case large
/// A button that is a regular size.
case medium
/// A button that is a small size.
case small
/// A (super/primary/secondary) button that should be place within a toolbar.
case toolbarIcon
}
private var font: Font {
if kind == .textLink, size == .small {
.compound.bodyMDSemibold
} else {
.compound.bodyLGSemibold
}
}
private var horizontalPadding: CGFloat {
if kind == .textLink {
return 0
}
return switch size {
case .large: 20
case .medium: 20
case .small: 16
case .toolbarIcon: 3
}
}
private var verticalPadding: CGFloat {
if kind == .textLink {
return 0
}
return switch size {
case .large: 14
case .medium: 7
case .small: 4
case .toolbarIcon: 3
}
}
private var maxWidth: CGFloat? {
if kind == .textLink {
return nil
}
return switch size {
case .large: .infinity
case .medium: nil
case .small: nil
case .toolbarIcon: nil
}
}
private var pressedOpacity: Double {
colorScheme == .light ? 0.3 : 0.6
}
private var isUnderlined: Bool {
kind == .textLink && accessibilityShowButtonShapes
}
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(font)
.underline(isUnderlined)
.multilineTextAlignment(.center)
.foregroundColor(textColor(configuration: configuration))
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.frame(maxWidth: maxWidth)
.background {
makeBackground(configuration: configuration)
}
.contentShape(contentShape)
}
@ViewBuilder
private func makeBackground(configuration: Self.Configuration) -> some View {
switch kind {
case .super:
if isEnabled {
ZStack {
Capsule().fill(.compound.bgCanvasDefault)
Capsule().fill(LinearGradient(gradient: .compound.action,
startPoint: .top, endPoint: .bottom))
.opacity(0.04)
Capsule().strokeBorder(LinearGradient(gradient: .compound.action,
startPoint: .top, endPoint: .bottom))
}
.compositingGroup()
.opacity(configuration.isPressed ? pressedOpacity : 1)
} else {
Capsule().strokeBorder(strokeColor(configuration: configuration))
}
case .primary:
Capsule().fill(fillColor(configuration: configuration))
case .secondary:
Capsule().strokeBorder(strokeColor(configuration: configuration))
case .tertiary:
EmptyView()
case .textLink:
EmptyView()
}
}
private var contentShape: AnyShape {
switch kind {
case .super, .primary, .secondary, .tertiary:
return AnyShape(Capsule())
case .textLink:
return AnyShape(Rectangle())
}
}
private func fillColor(configuration: Self.Configuration) -> Color {
guard isEnabled else { return .compound.bgActionPrimaryDisabled }
if configuration.role == .destructive {
return .compound.bgCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1)
} else {
return configuration.isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest
}
}
private func strokeColor(configuration: Self.Configuration) -> Color {
if configuration.role == .destructive {
return .compound.borderCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1)
} else {
return .compound.borderInteractiveSecondary.opacity(configuration.isPressed ? pressedOpacity : 1)
}
}
private func textColor(configuration: Configuration) -> Color {
if kind == .primary {
return .compound.textOnSolidPrimary
} else {
guard isEnabled else { return .compound.textDisabled }
let textColor: Color = configuration.role == .destructive ? .compound.textCriticalPrimary : .compound.textActionPrimary
return textColor.opacity(configuration.isPressed ? pressedOpacity : 1)
}
}
}
// MARK: - Previews
public struct CompoundButtonStyle_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
ScrollView {
states
}
.previewLayout(.fixed(width: 390, height: 1875))
}
@ViewBuilder
public static var states: some View {
Section {
buttons(.large)
} header: {
Header(title: "Large")
}
Section {
buttons(.medium)
} header: {
Header(title: "Medium")
}
Section {
buttons(.small)
} header: {
Header(title: "Small")
}
Section {
textLinks(.medium)
} header: {
Header(title: "Text Link")
}
Section {
textLinks(.small)
} header: {
Header(title: "Text Link Small")
}
Section {
startChat
.padding(.bottom) // Only for the snapshot.
} header: {
Header(title: "Start chat")
}
}
public static func buttons(_ size: CompoundButtonStyle.Size) -> some View {
VStack {
Button("Super") { }
.buttonStyle(.compound(.super, size: size))
Button("Disabled") { }
.buttonStyle(.compound(.super, size: size))
.disabled(true)
Button("Primary") { }
.buttonStyle(.compound(.primary, size: size))
Button("Destructive", role: .destructive) { }
.buttonStyle(.compound(.primary, size: size))
Button("Disabled") { }
.buttonStyle(.compound(.primary, size: size))
.disabled(true)
Button("Secondary") { }
.buttonStyle(.compound(.secondary, size: size))
Button("Destructive", role: .destructive) { }
.buttonStyle(.compound(.secondary, size: size))
Button("Disabled") { }
.buttonStyle(.compound(.secondary, size: size))
.disabled(true)
Button("Tertiary") { }
.buttonStyle(.compound(.tertiary, size: size))
Button("Destructive", role: .destructive) { }
.buttonStyle(.compound(.tertiary, size: size))
Button("Disabled") { }
.buttonStyle(.compound(.tertiary, size: size))
.disabled(true)
}
.padding(.horizontal)
}
static func textLinks(_ size: CompoundButtonStyle.Size) -> some View {
HStack(spacing: 20) {
Button("Text Link") { }
.buttonStyle(.compound(.textLink, size: size))
Button("Destructive", role: .destructive) { }
.buttonStyle(.compound(.textLink, size: size))
Button("Disabled") { }
.buttonStyle(.compound(.textLink, size: size))
.disabled(true)
}
.padding(.top, 1)
}
static var startChat: some View {
Button { } label: {
CompoundIcon(\.plus)
}
.buttonStyle(.compound(.super, size: .toolbarIcon))
}
struct Header: View {
let title: String
var body: some View {
Text(title)
.foregroundStyle(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding([.leading, .top])
.padding(.leading )
}
}
}
@@ -0,0 +1,16 @@
//
// Copyright 2022-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 Text {
/// Styles a text with the Compound design tokens to be displayed as a text field placeholder.
func compoundTextFieldPlaceholder() -> Text {
font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
}
}
@@ -0,0 +1,67 @@
//
// 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 ToggleStyle where Self == CompoundToggleStyle {
/// A toggle style that applies Compound design tokens to display a Switch row within a `Form`.
static var compound: CompoundToggleStyle {
CompoundToggleStyle()
}
}
/// Default toggle styling for form rows.
///
/// The toggle is given the form row label style and is tinted correctly.
public struct CompoundToggleStyle: ToggleStyle {
public func makeBody(configuration: Configuration) -> some View {
Toggle(isOn: configuration.$isOn) {
configuration.label
.foregroundColor(.compound.textPrimary)
}
.tint(.compound.iconAccentTertiary)
}
}
public struct CompoundToggleStyle_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
VStack(spacing: 16) {
states
}
.padding(32)
}
@ViewBuilder
public static var states: some View {
VStack(spacing: 16) {
Toggle("Title", isOn: .constant(false))
.toggleStyle(.compound)
.labelsHidden()
Toggle("Title", isOn: .constant(true))
.toggleStyle(.compound)
.labelsHidden()
}
.padding(.bottom, 32)
VStack(spacing: 16) {
Toggle("Title", isOn: .constant(true))
.toggleStyle(.compound)
Toggle("Title", isOn: .constant(false))
.toggleStyle(.compound)
Toggle(isOn: .constant(true)) {
Label("Title", systemImage: "square.dashed")
}
.toggleStyle(.compound)
Toggle(isOn: .constant(false)) {
Label("Title", systemImage: "square.dashed")
}
.toggleStyle(.compound)
}
}
}
@@ -0,0 +1,69 @@
//
// Copyright 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 button component for sending messages and media.
public struct SendButton: View {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
/// The action to perform when the user triggers the button.
public let action: () -> Void
private var iconColor: Color {
guard isEnabled else { return .compound.iconQuaternary }
return colorScheme == .light ? .compound.iconOnSolidPrimary : .compound.iconPrimary
}
private var gradient: Gradient { isEnabled ? .compound.action : .init(colors: [.clear]) }
/// Creates a send button that performs the provided action.
public init(action: @escaping () -> Void) {
self.action = action
}
public var body: some View {
Button(action: action) {
CompoundIcon(\.sendSolid, size: .medium, relativeTo: .compound.headingLG)
.foregroundStyle(iconColor)
.scaledPadding(6, relativeTo: .compound.headingLG)
.background { buttonShape }
.compositingGroup()
}
}
var buttonShape: some View {
Circle()
.fill(LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom))
}
}
// MARK: - Previews
public struct SendButton_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
VStack(spacing: 0) {
states
.padding(20)
.background(.compound.bgCanvasDefault)
states
.padding(20)
.background(.compound.bgCanvasDefault)
.environment(\.colorScheme, .dark)
}
.cornerRadius(20)
}
public static var states: some View {
HStack(spacing: 30) {
SendButton { }
.disabled(true)
SendButton { }
}
}
}
@@ -0,0 +1,115 @@
//
// 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 SwiftUI
public extension Color {
/// The colours used by Element as defined in Compound Design Tokens.
static let compound = CompoundColors()
}
public extension ShapeStyle where Self == Color {
/// The colours used by Element as defined in Compound Design Tokens.
static var compound: CompoundColors { Self.compound }
}
/// The colours used by Element as defined in Compound Design Tokens.
/// This struct contains only the colour tokens in a more usable form.
@dynamicMemberLookup
public class CompoundColors {
/// The base colour tokens that form the palette of available colours.
///
/// Normally these shouldn't be necessary, however in practice we may need
/// access for temporary tokens while waiting for official ones to be formalised.
private static let coreTokens = CompoundCoreColorTokens.self
/// The main semantic tokens generated from the Style Dictionary.
private let tokens: CompoundColorTokens
/// Runtime overrides for the `tokens` property.
private var overrides = [KeyPath<CompoundColorTokens, Color>: Color]()
public subscript(dynamicMember keyPath: KeyPath<CompoundColorTokens, Color>) -> Color {
return overrides[keyPath] ?? tokens[keyPath: keyPath]
}
/// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation.
public func override(_ keyPath: KeyPath<CompoundColorTokens, Color>, with color: Color?) {
overrides[keyPath] = color
}
init() {
let tokens = CompoundColorTokens()
self.tokens = tokens
decorativeColors = [
.init(background: tokens.bgDecorative1, text: tokens.textDecorative1),
.init(background: tokens.bgDecorative2, text: tokens.textDecorative2),
.init(background: tokens.bgDecorative3, text: tokens.textDecorative3),
.init(background: tokens.bgDecorative4, text: tokens.textDecorative4),
.init(background: tokens.bgDecorative5, text: tokens.textDecorative5),
.init(background: tokens.bgDecorative6, text: tokens.textDecorative6),
]
}
// MARK: - Decorative Colors
// Used to determine the background and text colors of avatars, usernames etc.
let decorativeColors: [DecorativeColor]
public func decorativeColor(for contentID: String) -> DecorativeColor {
decorativeColors[contentID.hashCode]
}
// MARK: - Awaiting Semantic Tokens
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _borderTextFieldFocused = coreTokens.gray500
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgBubbleIncoming = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray300) : UIColor(coreTokens.gray400) })
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgBubbleOutgoing = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray400) : UIColor(coreTokens.gray500) })
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCodeBlock = coreTokens.gray100
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _borderInteractiveSecondaryAlpha = coreTokens.alphaGray600
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCriticalSubtleAlpha = coreTokens.alphaRed300
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgEmptyItemAlpha = coreTokens.alphaGray500
}
private extension UITraitCollection {
/// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`.
var isLight: Bool { userInterfaceStyle == .light }
}
public struct DecorativeColor: Equatable {
public let background: Color
public let text: Color
}
private extension String {
/// Calculates a numeric hash same as Element Web
/// See original function here https://github.com/matrix-org/matrix-react-sdk/blob/321dd49db4fbe360fc2ff109ac117305c955b061/src/utils/FormattingUtils.js#L47
var hashCode: Int {
let characterCodeSum = self.reduce(0) { sum, character in
sum + Int(character.unicodeScalars.first?.value ?? 0)
}
return (characterCodeSum % Color.compound.decorativeColors.count)
}
}
@@ -0,0 +1,37 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import CompoundDesignTokens
import SwiftUI
public extension Gradient {
/// The gradients used by Element as defined in Compound Design Tokens.
static let compound = CompoundGradients()
}
/// The gradients used by Element as defined in Compound Design Tokens.
/// This struct only contains the gradients assembled from the individual colour stops.
public struct CompoundGradients {
// We need to use computed properties here so that the gradients include the
// latest token overrides that have been applied since the struct was created.
public var action: Gradient { .init(colors: [.compound.gradientActionStop1,
.compound.gradientActionStop2,
.compound.gradientActionStop3,
.compound.gradientActionStop4]) }
public var subtle: Gradient { .init(colors: [.compound.gradientSubtleStop1,
.compound.gradientSubtleStop2,
.compound.gradientSubtleStop3,
.compound.gradientSubtleStop4,
.compound.gradientSubtleStop5,
.compound.gradientSubtleStop6]) }
public var info: Gradient { .init(colors: [.compound.gradientInfoStop1,
.compound.gradientInfoStop2,
.compound.gradientInfoStop3,
.compound.gradientInfoStop4,
.compound.gradientInfoStop5,
.compound.gradientInfoStop6]) }
}
@@ -0,0 +1,53 @@
//
// 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 UIKit
public extension UIColor {
/// The colours used by Element as defined in Compound Design Tokens.
static let compound = CompoundUIColors()
}
/// The colours used by Element as defined in Compound Design Tokens.
/// This struct contains only the colour tokens in a more usable form.
@dynamicMemberLookup
public class CompoundUIColors {
/// The base colour tokens that form the palette of available colours.
///
/// Normally these shouldn't be necessary, however in practice we may need
/// access for temporary tokens while waiting for official ones to be formalised.
private static let coreTokens = CompoundCoreUIColorTokens.self
/// The main semantic tokens generated from the Style Dictionary.
private let tokens = CompoundUIColorTokens()
/// Runtime overrides for the `tokens` property.
private var overrides = [KeyPath<CompoundUIColorTokens, UIColor>: UIColor]()
public subscript(dynamicMember keyPath: KeyPath<CompoundUIColorTokens, UIColor>) -> UIColor {
return overrides[keyPath] ?? tokens[keyPath: keyPath]
}
/// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation.
public func override(_ keyPath: KeyPath<CompoundUIColorTokens, UIColor>, with color: UIColor?) {
overrides[keyPath] = color
}
// MARK: - Awaiting Semantic Tokens
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCodeBlock = coreTokens.gray100
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300
}
private extension UITraitCollection {
/// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`.
var isLight: Bool { userInterfaceStyle == .light }
}
@@ -0,0 +1,45 @@
//
// 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
@_spi(Advanced) import SwiftUIIntrospect
public extension PlatformViewVersionPredicate<NavigationStackType, UINavigationController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<WindowType, UIWindow> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<TextFieldType, UITextField> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<ScrollViewType, UIScrollView> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<ViewControllerType, UIViewController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<TabViewType, UITabBarController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
@@ -0,0 +1,52 @@
//
// 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 Font {
/// The fonts used by Element as defined in Compound Design Tokens.
static let compound = CompoundFonts()
}
/// A manual mapping of the Compound font styles to iOS styles. This will be
/// generated directly from the style dictionary in the future.
public struct CompoundFonts {
public let bodyXS = Font.caption
public let bodyXSSemibold = Font.caption.weight(.semibold)
public let bodySM = Font.footnote
public let bodySMSemibold = Font.footnote.weight(.semibold)
public let bodyMD = Font.subheadline
public let bodyMDSemibold = Font.subheadline.weight(.semibold)
public let bodyLG = Font.body
public let bodyLGSemibold = Font.body.weight(.semibold)
public let headingSM = Font.title3
public let headingSMSemibold = Font.title3.weight(.semibold)
public let headingMD = Font.title2
public let headingMDBold = Font.title2.bold()
public let headingLG = Font.title
public let headingLGBold = Font.title.bold()
public let headingXL = Font.largeTitle
public let headingXLBold = Font.largeTitle.bold()
}
public extension Font.TextStyle {
/// The text styles used by Element as defined in Compound Design Tokens.
static let compound = CompoundTextStyles()
}
/// A manual mapping of the Compound font styles to iOS text styles. This is useful
/// for `@ScaledMetric` along with modifiers such as `scaledPadding` etc.
public struct CompoundTextStyles {
public let bodyXS = Font.TextStyle.caption
public let bodySM = Font.TextStyle.footnote
public let bodyMD = Font.TextStyle.subheadline
public let bodyLG = Font.TextStyle.body
public let headingSM = Font.TextStyle.title3
public let headingMD = Font.TextStyle.title2
public let headingLG = Font.TextStyle.title
public let headingXL = Font.TextStyle.largeTitle
}
@@ -0,0 +1,72 @@
import SwiftUI
/// The size of a SwiftUI font.
enum FontSize {
case custom(CGFloat, Font.TextStyle?)
case style(Font.TextStyle)
/// The raw value in points.
var value: CGFloat {
switch self {
case .custom(let size, _):
return size
case .style(let style):
switch style {
case .largeTitle:
return UIFont.preferredFont(forTextStyle: .largeTitle).pointSize
case .title:
return UIFont.preferredFont(forTextStyle: .title1).pointSize
case .title2:
return UIFont.preferredFont(forTextStyle: .title2).pointSize
case .title3:
return UIFont.preferredFont(forTextStyle: .title3).pointSize
case .body:
return UIFont.preferredFont(forTextStyle: .body).pointSize
case .headline:
return UIFont.preferredFont(forTextStyle: .headline).pointSize
case .callout:
return UIFont.preferredFont(forTextStyle: .callout).pointSize
case .subheadline:
return UIFont.preferredFont(forTextStyle: .subheadline).pointSize
case .footnote:
return UIFont.preferredFont(forTextStyle: .footnote).pointSize
case .caption:
return UIFont.preferredFont(forTextStyle: .caption1).pointSize
case .caption2:
return UIFont.preferredFont(forTextStyle: .caption2).pointSize
@unknown default:
return UIFont.preferredFont(forTextStyle: .body).pointSize
}
}
}
/// The text style of the font.
var style: Font.TextStyle {
switch self {
case .custom(_, let textStyle):
return textStyle ?? .body
case .style(let textStyle):
return textStyle
}
}
static func reflecting(_ font: Font) -> FontSize? {
let mirror = Mirror(reflecting: font)
guard let provider = mirror.descendant("provider", "base") else { return nil }
return resolveFontSize(provider)
}
private static func resolveFontSize(_ provider: Any) -> FontSize? {
let mirror = Mirror(reflecting: provider)
if let size = mirror.descendant("size") as? CGFloat {
return .custom(size, mirror.descendant("textStyle") as? Font.TextStyle)
} else if let textStyle = mirror.descendant("style") as? Font.TextStyle {
return .style(textStyle)
}
// recurse to handle modifiers.
guard let provider = mirror.descendant("base", "provider", "base") else { return nil }
return resolveFontSize(provider)
}
}
@@ -0,0 +1,250 @@
//
// 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.
//
@_exported import CompoundDesignTokens
import SwiftUI
public extension Image {
/// The icons used by Element as defined in Compound Design Tokens.
static let compound = CompoundIcons()
}
/// A view that displays an icon from Compound. The icon defaults to a size of 24pt
/// and scales with Dynamic Type, relative to any font given to it by the `font` modifier.
public struct CompoundIcon: View {
/// The size of the icon.
public enum Size {
/// An icon size of 16pt.
case xSmall
/// An icon size of 20pt.
case small
/// An icon size of 24pt.
case medium
/// A custom icon size.
case custom(CGFloat)
var value: CGFloat {
switch self {
case .xSmall: return 16
case .small: return 20
case .medium: return 24
case .custom(let size): return size
}
}
}
private var image: Image
private var size: Size
private var font: Font
private var fontSize: FontSize {
FontSize.reflecting(font) ?? .style(.body)
}
/// Creates an icon using a key path from the Compound tokens. The size will be
/// 24pt and will scale relative to the `bodyLG` font when Dynamic Type is used.
///
/// - Parameters:
/// - icon: The icon to show.
public init(_ icon: KeyPath<CompoundIcons, Image>) {
image = .compound[keyPath: icon]
self.size = .medium
self.font = .compound.bodyLG
}
/// Creates an icon using a key path from the Compound tokens.
///
/// - Parameters:
/// - icon: The icon to show.
/// - size: The size of the icon.
/// - font: The font that should be used for scaling with Dynamic Type.
public init(_ icon: KeyPath<CompoundIcons, Image>, size: Size, relativeTo font: Font) {
image = .compound[keyPath: icon]
self.size = size
self.font = font
}
/// Creates an icon using a custom image to allow assets from outside
/// of Compound to scale in the same way as icons. The size will be 24pt
/// and will scale relative to the `bodyLG` font when Dynamic Type is used.
///
/// - Parameters:
/// - customImage: The image that should be displayed
///
/// ** Note:** The image should have a square frame or it may end up distorted.
public init(customImage: Image) {
image = customImage
self.size = .medium
self.font = .compound.bodyLG
}
/// Creates an icon using a custom image to allow assets from outside
/// of Compound to scale in the same way as icons.
///
/// - Parameters:
/// - customImage: The image that should be displayed
/// - size: The size of the icon.
/// - font: The font that should be used for scaling with Dynamic Type.
///
/// ** Note:** The image should have a square frame or it may end up distorted.
public init(customImage: Image, size: Size, relativeTo font: Font) {
image = customImage
self.size = size
self.font = font
}
public var body: some View {
image
.resizable()
.modifier(CompoundIconFrame(fontSize: size.value, textStyle: fontSize.style))
}
}
/// A simple modifier that applies a square frame of a given size that will be
/// scaled dynamically based upon the specified text style.
private struct CompoundIconFrame: ViewModifier {
@ScaledMetric private var size: CGFloat
init(fontSize: CGFloat, textStyle: Font.TextStyle) {
_size = ScaledMetric(wrappedValue: fontSize, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content
.frame(width: size, height: size)
}
}
public extension Label {
/// Creates a label with an icon from Compound and a title generated from a string.
/// The icon size will be 24pt, scaling relative to the `bodyLG` with Dynamic Type.
/// - Parameters:
/// - title: A string used as the label’s title.
/// - icon: The icon to use from Compound.
init(_ title: some StringProtocol, icon: KeyPath<CompoundIcons, Image>) where Title == Text, Icon == CompoundIcon {
self.init {
Text(title)
} icon: {
CompoundIcon(icon)
}
}
/// Creates a label with an icon from Compound and a title generated from a string.
/// - Parameters:
/// - title: A string used as the label’s title.
/// - icon: The icon to use from Compound.
/// - iconSize: The size of the icon.
/// - font: The font that the icon should scale relative to with Dynamic Type.
init(_ title: some StringProtocol,
icon: KeyPath<CompoundIcons, Image>,
iconSize: CompoundIcon.Size,
relativeTo font: Font) where Title == Text, Icon == CompoundIcon {
self.init {
Text(title)
} icon: {
CompoundIcon(icon, size: iconSize, relativeTo: font)
}
}
}
// MARK: - Previews
struct CompoundIcon_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
form
.previewDisplayName("Form")
buttons
.padding(8)
.previewLayout(.sizeThatFits)
.previewDisplayName("Buttons")
accessibilityIcons
.previewDisplayName("Accessibility Icons Only")
accessibilityLabels
.previewDisplayName("Accessibility Labels")
}
static var accessibilityIcons: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
HStack {
CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS)
CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM)
CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG)
}
.dynamicTypeSize(size)
}
}
}
static var accessibilityLabels: some View {
Grid(alignment: .leading) {
ForEach(DynamicTypeSize.allCases, id: \.self) {
size in
GridRow {
Label("Test XS", icon: \.userProfile, iconSize: .xSmall, relativeTo: .compound.bodyXS)
.font(.compound.bodyXS)
Label("Test Small", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySM)
.font(.compound.bodySM)
Label("Test Medium", icon: \.userProfile, iconSize: .medium, relativeTo: .compound.bodyLG)
.font(.compound.bodyLG)
}
.lineLimit(1)
.dynamicTypeSize(size)
}
}
}
static var form: some View {
Form {
Section {
ListRow(label: .action(title: "Plain Icon", icon: \.userProfile),
kind: .label)
ListRow(label: .default(title: "Plain Icon", icon: \.userProfile),
kind: .label)
ListRow(label: .default(title: "Plain Icon", systemIcon: .personCropCircle),
kind: .label)
}
}
.compoundList()
.safeAreaInset(edge: .bottom) {
Button { } label: {
Label("Button", icon: \.userProfile)
}
.buttonStyle(.compound(.primary))
.padding()
}
}
static var buttons: some View {
VStack {
Button { } label: {
Label { Text("Body Large") } icon: {
CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG)
}
}
.font(.compound.bodyLG)
.buttonStyle(.borderedProminent)
Button { } label: {
Label { Text("Body Small") } icon: {
CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM)
}
}
.font(.compound.bodySM)
.buttonStyle(.borderedProminent)
Button { } label: {
Label { Text("Body xSmall") } icon: {
CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS)
}
}
.font(.compound.bodyXS)
.buttonStyle(.borderedProminent)
}
}
}
@@ -0,0 +1,56 @@
//
// 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 {
/// Positions this view within an invisible frame with a square size that scales relative to the user's selected font size.
func scaledFrame(size: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View {
scaledFrame(width: size, height: size, alignment: alignment, relativeTo: textStyle)
}
/// Positions this view within an invisible frame with a size that scales relative to the user's selected font size.
func scaledFrame(width: CGFloat, height: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledFrameModifier(width: width, height: height, alignment: alignment, relativeTo: textStyle))
}
}
private struct ScaledFrameModifier: ViewModifier {
@ScaledMetric var width: CGFloat
@ScaledMetric var height: CGFloat
let alignment: Alignment
init(width: CGFloat, height: CGFloat, alignment: Alignment, relativeTo textStyle: Font.TextStyle) {
_width = ScaledMetric(wrappedValue: width, relativeTo: textStyle)
_height = ScaledMetric(wrappedValue: height, relativeTo: textStyle)
self.alignment = alignment
}
func body(content: Content) -> some View {
content.frame(width: width, height: height, alignment: alignment)
}
}
// MARK: - Previews
struct ScaledFrameModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
likeButtonLabel
.dynamicTypeSize(size)
}
}
}
static var likeButtonLabel: some View {
Image(systemSymbol: .heartCircleFill)
.resizable()
.foregroundStyle(.compound.iconAccentTertiary)
.scaledFrame(size: 24, relativeTo: .title)
}
}
@@ -0,0 +1,53 @@
//
// Copyright 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 {
/// Offset this view by the specified horizontal and vertical distances, scaled relative to the user's selected font size.
func scaledOffset(x: CGFloat = 0, y: CGFloat = 0, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledOffsetModifier(x: x, y: y, relativeTo: textStyle))
}
}
private struct ScaledOffsetModifier: ViewModifier {
@ScaledMetric var x: CGFloat
@ScaledMetric var y: CGFloat
init(x: CGFloat, y: CGFloat, relativeTo textStyle: Font.TextStyle) {
_x = ScaledMetric(wrappedValue: x, relativeTo: textStyle)
_y = ScaledMetric(wrappedValue: y, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.offset(x: x, y: y)
}
}
// MARK: - Previews
struct ScaledOffsetModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
verifiedUserComposite
.dynamicTypeSize(size)
}
}
}
static var verifiedUserComposite: some View {
CompoundIcon(\.userSolid)
.foregroundStyle(.compound.iconAccentTertiary)
.overlay {
CompoundIcon(\.verified, size: .custom(10), relativeTo: .body)
.scaledOffset(x: 6, y: 6)
.foregroundStyle(.compound.gradientActionStop1)
}
}
}
@@ -0,0 +1,54 @@
//
// 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 {
/// Adds an equal padding amount to all edges of this view, scaled relative to the user's selected font size.
func scaledPadding(_ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View {
scaledPadding(.all, length, relativeTo: textStyle)
}
/// Adds an equal padding amount to specific edges of this view, scaled relative to the user's selected font size.
func scaledPadding(_ edges: Edge.Set, _ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledPaddingModifier(edges: edges, length: length, textStyle: textStyle))
}
}
private struct ScaledPaddingModifier: ViewModifier {
let edges: Edge.Set
@ScaledMetric var length: CGFloat
init(edges: Edge.Set, length: CGFloat, textStyle: Font.TextStyle) {
self.edges = edges
_length = ScaledMetric(wrappedValue: length, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.padding(edges, length)
}
}
// MARK: - Previews
struct ScaledPaddingModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
userProfileButtonLabel
.dynamicTypeSize(size)
}
}
}
static var userProfileButtonLabel: some View {
CompoundIcon(\.userProfile, size: .medium, relativeTo: .title)
.foregroundStyle(.compound.iconOnSolidPrimary)
.scaledPadding(6, relativeTo: .title)
.background(.compound.bgAccentRest, in: Circle())
}
}
@@ -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")
}
}
@@ -0,0 +1,42 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
struct SnapshotPrecisionPreferenceKey: PreferenceKey {
static var defaultValue: Float = 1.0
static func reduce(value: inout Float, nextValue: () -> Float) {
value = nextValue()
}
}
struct SnapshotPerceptualPrecisionPreferenceKey: PreferenceKey {
static var defaultValue: Float = 0.98
static func reduce(value: inout Float, nextValue: () -> Float) {
value = nextValue()
}
}
extension SwiftUI.View {
/// Use this modifier when you want to apply snapshot-specific preferences,
/// like delay and precision, to the view.
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
///
/// - Parameters:
/// - delay: The delay time in seconds that you want to set as a preference to the View.
/// - precision: The percentage of pixels that must match.
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye.
func snapshotPreferences(expect fulfillmentPublisher: (any Publisher<Bool, Never>)? = nil,
precision: Float = 1.0,
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
.preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision)
}
}
@@ -0,0 +1,8 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
protocol TestablePreview { }
@@ -0,0 +1,102 @@
//
// 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
import SwiftUIIntrospect
public extension View {
/// Styles a search bar text field using the Compound design tokens.
/// This modifier is to be used in combination with `.searchable`.
@MainActor
@ViewBuilder
func compoundSearchField() -> some View {
if #available(iOS 26, *) {
self
} else {
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
// Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow.
guard let searchController = navigationController.navigationBar.topItem?.searchController else { return }
// Ported from Riot iOS as this is the only reliable way to get the exact look we want.
// However this is fragile and tied to gutwrenching the current UISearchBar internals.
let textColor = UIColor.compound.textPrimary
let placeholderColor = UIColor.compound.textSecondary
let textFieldTintColor = UIColor.compound.iconAccentTertiary
let textFieldBackgroundColor = UIColor.compound._bgSubtleSecondaryAlpha
let searchTextField = searchController.searchBar.searchTextField
// Magnifying glass icon.
let leftImageView = searchTextField.leftView as? UIImageView
leftImageView?.tintColor = placeholderColor
// Placeholder text.
let placeholderLabel = searchTextField.value(forKey: "placeholderLabel") as? UILabel
placeholderLabel?.textColor = placeholderColor
// Clear button.
let clearButton = searchTextField.value(forKey: "clearButton") as? UIButton
let buttonImage = clearButton?.image(for: .normal)?.withRenderingMode(.alwaysTemplate)
clearButton?.setImage(buttonImage, for: .normal)
clearButton?.tintColor = placeholderColor
// Text field.
searchTextField.textColor = textColor
searchTextField.backgroundColor = textFieldBackgroundColor
searchTextField.tintColor = textFieldTintColor
// Hide the effect views so we can use the rounded rect style without any materials.
let effectBackgroundTop = searchTextField.value(forKey: "_effectBackgroundTop") as? UIView
effectBackgroundTop?.isHidden = true
let effectBackgroundBottom = searchTextField.value(forKey: "_effectBackgroundBottom") as? UIView
effectBackgroundBottom?.isHidden = false
}
}
}
}
// MARK: - Previews
struct SearchStyle_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
NavigationStack {
List {
ForEach(0..<10, id: \.self) { index in
Text("Item \(index)")
}
}
.listStyle(.plain)
.searchable(text: .constant(""))
.compoundSearchField()
}
.tint(.compound.textActionPrimary)
.previewDisplayName("List")
NavigationStack {
Form {
Section {
ListRow(label: .plain(title: "Some row"),
kind: .button { })
} header: {
Text("Settings")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "Some setting"),
kind: .toggle(.constant(true)))
} header: {
Text("More Settings")
.compoundListSectionHeader()
}
}
.compoundList()
.searchable(text: .constant(""))
.compoundSearchField()
}
.tint(.compound.textActionPrimary)
.previewDisplayName("Form")
}
}