diff --git a/Snapshots/iPad/ButtonTests/testButtons.1.png b/Snapshots/iPad/ButtonTests/testButtons.1.png index 099bb52905f..21cd4eb68f0 100644 Binary files a/Snapshots/iPad/ButtonTests/testButtons.1.png and b/Snapshots/iPad/ButtonTests/testButtons.1.png differ diff --git a/Snapshots/iPad/ButtonTests/testButtons.2.png b/Snapshots/iPad/ButtonTests/testButtons.2.png index c9d4f48c080..26d205621d3 100644 Binary files a/Snapshots/iPad/ButtonTests/testButtons.2.png and b/Snapshots/iPad/ButtonTests/testButtons.2.png differ diff --git a/Snapshots/iPad/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png b/Snapshots/iPad/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png index 7570f2eeb56..d1ad4758435 100644 Binary files a/Snapshots/iPad/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png and b/Snapshots/iPad/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png differ diff --git a/Snapshots/iPhone/ButtonTests/testButtons.1.png b/Snapshots/iPhone/ButtonTests/testButtons.1.png index 980dd22be2a..1f129521706 100644 Binary files a/Snapshots/iPhone/ButtonTests/testButtons.1.png and b/Snapshots/iPhone/ButtonTests/testButtons.1.png differ diff --git a/Snapshots/iPhone/ButtonTests/testButtons.2.png b/Snapshots/iPhone/ButtonTests/testButtons.2.png index 526ee47b09c..88be44550e0 100644 Binary files a/Snapshots/iPhone/ButtonTests/testButtons.2.png and b/Snapshots/iPhone/ButtonTests/testButtons.2.png differ diff --git a/Snapshots/iPhone/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png b/Snapshots/iPhone/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png index 43e177bf917..84fc5b2a2d4 100644 Binary files a/Snapshots/iPhone/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png and b/Snapshots/iPhone/ScreenLayoutModifierTests/testScreenLayoutModifier.1.png differ diff --git a/Sources/Orbit/Components/Button.swift b/Sources/Orbit/Components/Button.swift index f16d4e2b676..cbc4c786e09 100644 --- a/Sources/Orbit/Components/Button.swift +++ b/Sources/Orbit/Components/Button.swift @@ -6,137 +6,35 @@ import SwiftUI /// - Important: Component expands horizontally unless prevented by `fixedSize` or `idealSize` modifier. public struct Button: View { - @Environment(\.status) private var status - @Environment(\.iconColor) private var iconColor - @Environment(\.idealSize) private var idealSize - @Environment(\.textColor) private var textColor - @Environment(\.textLinkColor) private var textLinkColor - @Environment(\.isHapticsEnabled) private var isHapticsEnabled - private let label: String private let type: ButtonType private let size: ButtonSize private let action: () -> Void - @ViewBuilder private let icon: LeadingIcon - @ViewBuilder private let disclosureIcon: TrailingIcon + @ViewBuilder private let leadingIcon: LeadingIcon + @ViewBuilder private let trailingIcon: TrailingIcon public var body: some View { - SwiftUI.Button( - action: { - if isHapticsEnabled { - HapticsProvider.sendHapticFeedback(hapticFeedback) - } - - action() - }, - label: { - HStack(spacing: 0) { - TextStrut(size.textSize) - - if disclosureIcon.isEmpty, idealSize.horizontal == nil { - Spacer(minLength: 0) - } - - HStack(spacing: .xSmall) { - icon - .font(.system(size: size.textSize.value)) - .iconColor(iconColor) - .foregroundColor(iconColor) - - textWrapper - } - - if idealSize.horizontal == nil { - Spacer(minLength: 0) - } - - disclosureIcon - .font(.system(size: size.textSize.value)) - .iconColor(iconColor) - .foregroundColor(iconColor) - } - .textColor(resolvedTextColor) - .padding(.vertical, size.verticalPadding) - .padding(.leading, leadingPadding) - .padding(.trailing, trailingPadding) - } - ) - .buttonStyle(OrbitButtonStyle(type: type, status: status, size: size)) - .frame(maxWidth: idealSize.horizontal == true ? nil : .infinity) - } - - @ViewBuilder var textWrapper: some View { - if #available(iOS 14.0, *) { - text - } else { - text + SwiftUI.Button(action: action) { + if #available(iOS 14, *) { + text + } else { + text // Prevents text value animation issue due to different iOS13 behavior - .animation(nil) + .animation(nil) + } } + .buttonStyle( + .orbit(type: type, size: size) { + leadingIcon + } trailingIcon: { + trailingIcon + } + ) } @ViewBuilder var text: some View { Text(label, size: size.textSize) .fontWeight(.medium) - .textLinkColor(textLinkColor ?? .custom(resolvedTextColor)) - } - - var resolvedTextColor: Color { - textColor ?? styleTextColor - } - - var styleTextColor: Color { - switch type { - case .primary: return .whiteNormal - case .primarySubtle: return .productDark - case .secondary: return .inkDark - case .critical: return .whiteNormal - case .criticalSubtle: return .redDark - case .status(_, false): return .whiteNormal - case .status(let status, true): return (status ?? defaultStatus).darkHoverColor - case .gradient: return .whiteNormal - } - } - - var isIconOnly: Bool { - icon.isEmpty == false && label.isEmpty - } - - var leadingPadding: CGFloat { - label.isEmpty == false && icon.isEmpty - ? size.horizontalPadding - : size.horizontalIconPadding - } - - var trailingPadding: CGFloat { - label.isEmpty == false && disclosureIcon.isEmpty - ? size.horizontalPadding - : size.horizontalIconPadding - } - - var defaultStatus: Status { - status ?? .info - } - - var resolvedStatus: Status { - switch type { - case .status(let status, _): return status ?? defaultStatus - default: return .info - } - } - - var hapticFeedback: HapticsProvider.HapticFeedbackType { - switch type { - case .primary: return .light(1) - case .primarySubtle, .secondary, .gradient: return .light(0.5) - case .critical, .criticalSubtle: return .notification(.error) - case .status: - switch resolvedStatus { - case .info, .success: return .light(0.5) - case .warning: return .notification(.warning) - case .critical: return .notification(.error) - } - } } } @@ -183,8 +81,8 @@ public extension Button { self.type = type self.size = size self.action = action - self.icon = icon() - self.disclosureIcon = disclosureIcon() + self.leadingIcon = icon() + self.trailingIcon = disclosureIcon() } } @@ -234,27 +132,89 @@ public enum ButtonSize { } } -public struct OrbitButtonStyle: ButtonStyle { +public struct OrbitButtonStyle: PrimitiveButtonStyle { + + @Environment(\.iconColor) private var iconColor + @Environment(\.idealSize) private var idealSize + @Environment(\.isHapticsEnabled) private var isHapticsEnabled + @Environment(\.sizeCategory) var sizeCategory + @Environment(\.status) private var status + @Environment(\.textColor) private var textColor + @Environment(\.textLinkColor) private var textLinkColor + @State private var isPressed = false var type: ButtonType - var status: Status? var size: ButtonSize - - public init(type: ButtonType, status: Status? = nil, size: ButtonSize) { + var cornerRadius: CGFloat + let icon: LeadingIcon + let disclosureIcon: TrailingIcon + + public init( + type: ButtonType, + size: ButtonSize, + cornerRadius: CGFloat = BorderRadius.default, + @ViewBuilder icon: () -> LeadingIcon, + @ViewBuilder trailingIcon: () -> TrailingIcon + ) { self.type = type - self.status = status self.size = size + self.cornerRadius = cornerRadius + self.icon = icon() + self.disclosureIcon = trailingIcon() } public func makeBody(configuration: Configuration) -> some View { - configuration.label - .contentShape(Rectangle()) - .background(background(for: configuration)) - .cornerRadius(BorderRadius.default) + content(configuration.label) + ._onButtonGesture { isPressed in + self.isPressed = isPressed + } perform: { + if isHapticsEnabled { + HapticsProvider.sendHapticFeedback(hapticFeedback) + } + + configuration.trigger() + } + } + + @ViewBuilder func content(_ label: some View) -> some View { + HStack(spacing: 0) { + TextStrut(size.textSize) + + if disclosureIcon.isEmpty, idealSize.horizontal == nil { + Spacer(minLength: 0) + } + + HStack(spacing: .xSmall) { + icon + .font(.system(size: size.textSize.value)) + .iconColor(iconColor) + .foregroundColor(iconColor) + + label + .textLinkColor(textLinkColor ?? .custom(resolvedTextColor)) + } + + if idealSize.horizontal == nil { + Spacer(minLength: 0) + } + + disclosureIcon + .font(.system(size: size.textSize.value)) + .iconColor(iconColor) + .foregroundColor(iconColor) + } + .textColor(resolvedTextColor) + .padding(.leading, leadingPadding) + .padding(.trailing, trailingPadding) + .frame(maxWidth: idealSize.horizontal == true ? nil : .infinity) + .padding(.vertical, size.verticalPadding) + .contentShape(Rectangle()) + .background(background(forPressedState: isPressed)) + .cornerRadius(cornerRadius) } - @ViewBuilder func background(for configuration: Configuration) -> some View { - if configuration.isPressed { + @ViewBuilder func background(forPressedState isPressed: Bool) -> some View { + if isPressed { backgroundActive } else { background @@ -290,6 +250,95 @@ public struct OrbitButtonStyle: ButtonStyle { var defaultStatus: Status { status ?? .info } + + var resolvedTextColor: Color { + textColor ?? styleTextColor + } + + var styleTextColor: Color { + switch type { + case .primary: return .whiteNormal + case .primarySubtle: return .productDark + case .secondary: return .inkDark + case .critical: return .whiteNormal + case .criticalSubtle: return .redDark + case .status(_, false): return .whiteNormal + case .status(let status, true): return (status ?? defaultStatus).darkHoverColor + case .gradient: return .whiteNormal + } + } + + var resolvedStatus: Status { + switch type { + case .status(let status, _): return status ?? self.status ?? .info + default: return .info + } + } + + var hapticFeedback: HapticsProvider.HapticFeedbackType { + switch type { + case .primary: return .light(1) + case .primarySubtle, .secondary, .gradient: return .light(0.5) + case .critical, .criticalSubtle: return .notification(.error) + case .status: + switch resolvedStatus { + case .info, .success: return .light(0.5) + case .warning: return .notification(.warning) + case .critical: return .notification(.error) + } + } + } + + var leadingPadding: CGFloat { + icon.isEmpty && disclosureIcon.isEmpty + ? size.horizontalPadding + : size.horizontalIconPadding + } + + var trailingPadding: CGFloat { + icon.isEmpty && disclosureIcon.isEmpty + ? size.horizontalPadding + : size.horizontalIconPadding + } +} + +public extension PrimitiveButtonStyle { + + static func orbit( + type: ButtonType, + size: ButtonSize, + cornerRadius: CGFloat = BorderRadius.default, + @ViewBuilder leadingIcon: () -> LeadingIcon, + @ViewBuilder trailingIcon: () -> TrailingIcon + ) -> Self where Self == OrbitButtonStyle { + Self(type: type, size: size, cornerRadius: cornerRadius, icon: leadingIcon, trailingIcon: trailingIcon) + } + + static func orbit( + type: ButtonType, + size: ButtonSize, + cornerRadius: CGFloat = BorderRadius.default, + @ViewBuilder leadingIcon: () -> LeadingIcon + ) -> Self where Self == OrbitButtonStyle { + Self(type: type, size: size, cornerRadius: cornerRadius, icon: leadingIcon, trailingIcon: { EmptyView() }) + } + + static func orbit( + type: ButtonType, + size: ButtonSize, + cornerRadius: CGFloat = BorderRadius.default, + @ViewBuilder trailingIcon: () -> TrailingIcon + ) -> Self where Self == OrbitButtonStyle { + Self(type: type, size: size, cornerRadius: cornerRadius, icon: { EmptyView() }, trailingIcon: trailingIcon) + } + + static func orbit( + type: ButtonType, + size: ButtonSize, + cornerRadius: CGFloat = BorderRadius.default + ) -> Self where Self == OrbitButtonStyle { + Self(type: type, size: size, cornerRadius: cornerRadius, icon: { EmptyView() }, trailingIcon: { EmptyView() }) + } } public struct ButtonContent: ExpressibleByStringLiteral { diff --git a/Sources/Orbit/Components/Text.swift b/Sources/Orbit/Components/Text.swift index b9f71cb469b..e4806e6996b 100644 --- a/Sources/Orbit/Components/Text.swift +++ b/Sources/Orbit/Components/Text.swift @@ -32,6 +32,10 @@ public struct Text: View, FormattedTextBuildable { var isUnderline: Bool? var isMonospacedDigit: Bool? + var isEmpty: Bool { + content.isEmpty + } + // The Orbit Text consists of up to 3 layers: // // 1) SwiftUI.Text base layer, either: @@ -41,7 +45,7 @@ public struct Text: View, FormattedTextBuildable { // 3) Long-tap-to-copy gesture overlay (when isSelectable == true) public var body: some View { - if content.isEmpty == false { + if isEmpty == false { text(textRepresentableEnvironment: textRepresentableEnvironment) .lineSpacing(lineSpacingAdjusted(sizeCategory: sizeCategory)) .overlay(selectableLabelWrapper) @@ -358,7 +362,7 @@ extension TextAlignment { extension Text: TextRepresentable { public func swiftUIText(textRepresentableEnvironment: TextRepresentableEnvironment) -> SwiftUI.Text? { - if content.isEmpty { return nil } + if isEmpty { return nil } return text(textRepresentableEnvironment: textRepresentableEnvironment, isConcatenated: true) } diff --git a/Sources/Orbit/Support/Layout/IsEmpty.swift b/Sources/Orbit/Support/Layout/IsEmpty.swift index 4ecd54ca29e..d26b785121b 100644 --- a/Sources/Orbit/Support/Layout/IsEmpty.swift +++ b/Sources/Orbit/Support/Layout/IsEmpty.swift @@ -3,6 +3,6 @@ import SwiftUI extension View { var isEmpty: Bool { - self is EmptyView || (self as? Orbit.Icon)?.isEmpty == true + self is EmptyView || (self as? Orbit.Icon)?.isEmpty == true || (self as? Orbit.Text)?.isEmpty == true } }