Files
letro-ios/compound-ios/Sources/Compound/BaseStyles/CompoundButtonStyle.swift
2026-04-16 21:57:56 +04:00

328 lines
10 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-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 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:
makePrimaryBackground(configuration: configuration)
case .secondary:
Capsule().strokeBorder(strokeColor(configuration: configuration))
case .tertiary:
EmptyView()
case .textLink:
EmptyView()
}
}
// Letro: custom action buttons
@ViewBuilder
private func makePrimaryBackground(configuration: Self.Configuration) -> some View {
if !isEnabled || configuration.role == .destructive {
Capsule().fill(fillColor(configuration: configuration))
} else {
Capsule()
.fill(LinearGradient(gradient: .compound.action,
startPoint: .bottomLeading,
endPoint: .topTrailing))
.opacity(configuration.isPressed ? pressedOpacity : 1)
}
}
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(spacing: 8) {
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)
}
}
}