mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-09 18:33:16 -06:00
## Summary Only `HAButtonStyle` (primary) had hover effects implemented. The other 7 button style variants lacked this functionality, creating inconsistent user interaction feedback. **Primary Button (HAButtonStyle):** Maintains its original full hover behavior with both visual and interaction feedback: - Background color opacity change: `Color.haPrimary` → `Color.haPrimary.opacity(0.9)` on hover - Scale effects: 1.02x on hover, 0.95x on press - Uses direct `onHover` implementation for complete control **Other Button Styles:** Created `HAButtonHoverEffectModifier` - a reusable view modifier that provides scale-based hover behavior: - Scale effects: 1.02x on hover, 0.95x on press - Animations: 0.1s easeInOut transitions - State-aware: Only activates when button is enabled - Platform-specific: Excluded on watchOS Applied to: - `HAOutlinedButtonStyle`, `HANeutralButtonStyle`, `HANegativeButtonStyle`, `HASecondaryButtonStyle`, `HASecondaryNegativeButtonStyle`, `HACriticalButtonStyle`, `HALinkButtonStyle` Added `@Environment(\.isEnabled)` to `HACriticalButtonStyle` and `HALinkButtonStyle` where previously missing. ## Screenshots N/A - Interactive hover effect, existing snapshot tests cover visual appearance ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes Changes maintain backward compatibility with no breaking API changes. Primary button retains original hover behavior (background + scale), while other buttons gain new scale-based hover effects for consistent user feedback. <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > Check HAButtonStyle, the primary style has hover effect set but not the others, please implement a single logic and apply for all </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
299 lines
10 KiB
Swift
299 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
enum HAButtonStylesConstants {
|
|
static var cornerRadius: CGFloat = 12
|
|
static var disabledOpacity: CGFloat = 0.5
|
|
static var horizontalPadding: CGFloat = 20
|
|
static var highlightedOpacity: CGFloat = 0.8
|
|
static var highlightedScale: CGFloat = 0.95
|
|
static var hoverOpacity: CGFloat = 0.9
|
|
static var hoverScale: CGFloat = 1.02
|
|
static var animationDuration: Double = 0.1
|
|
}
|
|
|
|
public struct HAButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
@State private var isHovering = false
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.background(backgroundColorForState(
|
|
isEnabled: isEnabled,
|
|
isPressed: configuration.isPressed,
|
|
isHovering: isHovering
|
|
))
|
|
.clipShape(Capsule())
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.scaleEffect(scaleForState(isPressed: configuration.isPressed, isHovering: isHovering))
|
|
.animation(.easeInOut(duration: HAButtonStylesConstants.animationDuration), value: configuration.isPressed)
|
|
.animation(.easeInOut(duration: HAButtonStylesConstants.animationDuration), value: isHovering)
|
|
#if !os(watchOS)
|
|
.onHover { hovering in
|
|
if isEnabled {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func backgroundColorForState(isEnabled: Bool, isPressed: Bool, isHovering: Bool) -> Color {
|
|
if !isEnabled {
|
|
return Color.gray
|
|
}
|
|
|
|
if isPressed {
|
|
return Color.haPrimary.opacity(HAButtonStylesConstants.highlightedOpacity)
|
|
}
|
|
|
|
if isHovering {
|
|
return Color.haPrimary.opacity(HAButtonStylesConstants.hoverOpacity)
|
|
}
|
|
|
|
return Color.haPrimary
|
|
}
|
|
|
|
private func scaleForState(isPressed: Bool, isHovering: Bool) -> CGFloat {
|
|
if isPressed {
|
|
return HAButtonStylesConstants.highlightedScale
|
|
}
|
|
|
|
if isHovering {
|
|
return HAButtonStylesConstants.hoverScale
|
|
}
|
|
|
|
return 1.0
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
VStack {
|
|
Button("Primary Button") {}
|
|
.buttonStyle(.primaryButton)
|
|
}
|
|
}
|
|
|
|
public struct HAOutlinedButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.headline)
|
|
.foregroundColor(isEnabled ? Color.haPrimary : Color.gray)
|
|
.haButtonFlexSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(isEnabled ? Color.haColorBorderPrimaryQuiet : Color.gray, lineWidth: 1)
|
|
)
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HANeutralButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.callout.bold())
|
|
.foregroundColor(.white)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.background(Color.gray)
|
|
.clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius))
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HANegativeButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.callout.bold())
|
|
.foregroundColor(.white)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.background(isEnabled ? .red : Color.gray)
|
|
.clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius))
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HASecondaryButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.callout.bold())
|
|
.foregroundColor(Color.haPrimary)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius))
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HASecondaryNegativeButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.callout.bold())
|
|
.foregroundColor(.red)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius))
|
|
.opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HACriticalButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.multilineTextAlignment(.center)
|
|
.font(.callout.bold())
|
|
.foregroundColor(.black)
|
|
.haButtonBasicSizing()
|
|
.padding(.horizontal, HAButtonStylesConstants.horizontalPadding)
|
|
.background(.red.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius))
|
|
.overlay(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius).stroke(
|
|
Color.red,
|
|
lineWidth: 1
|
|
))
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public struct HALinkButtonStyle: ButtonStyle {
|
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
|
|
|
public func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.footnote)
|
|
.foregroundColor(Color.haPrimary)
|
|
.frame(maxWidth: DesignSystem.Button.maxWidth)
|
|
.haButtonHoverEffect(isEnabled: isEnabled, isPressed: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HAButtonStyle {
|
|
static var primaryButton: HAButtonStyle {
|
|
HAButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HAOutlinedButtonStyle {
|
|
static var outlinedButton: HAOutlinedButtonStyle {
|
|
HAOutlinedButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HANegativeButtonStyle {
|
|
static var negativeButton: HANegativeButtonStyle {
|
|
HANegativeButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HANeutralButtonStyle {
|
|
static var neutralButton: HANeutralButtonStyle {
|
|
HANeutralButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HASecondaryButtonStyle {
|
|
static var secondaryButton: HASecondaryButtonStyle {
|
|
HASecondaryButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HASecondaryNegativeButtonStyle {
|
|
static var secondaryNegativeButton: HASecondaryNegativeButtonStyle {
|
|
HASecondaryNegativeButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HALinkButtonStyle {
|
|
static var linkButton: HALinkButtonStyle {
|
|
HALinkButtonStyle()
|
|
}
|
|
}
|
|
|
|
public extension ButtonStyle where Self == HACriticalButtonStyle {
|
|
static var criticalButton: HACriticalButtonStyle {
|
|
HACriticalButtonStyle()
|
|
}
|
|
}
|
|
|
|
private struct HABasicStylingModifier: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.frame(minHeight: DesignSystem.Button.minHeight)
|
|
.frame(maxWidth: DesignSystem.Button.maxWidth)
|
|
}
|
|
}
|
|
|
|
private struct HAFlexStylingModifier: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.frame(minHeight: DesignSystem.Button.minHeight)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func haButtonBasicSizing() -> some View {
|
|
modifier(HABasicStylingModifier())
|
|
}
|
|
|
|
func haButtonFlexSizing() -> some View {
|
|
modifier(HAFlexStylingModifier())
|
|
}
|
|
|
|
func haButtonHoverEffect(isEnabled: Bool, isPressed: Bool) -> some View {
|
|
modifier(HAButtonHoverEffectModifier(isEnabled: isEnabled, isPressed: isPressed))
|
|
}
|
|
}
|
|
|
|
private struct HAButtonHoverEffectModifier: ViewModifier {
|
|
let isEnabled: Bool
|
|
let isPressed: Bool
|
|
@State private var isHovering = false
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.scaleEffect(scaleForState(isPressed: isPressed, isHovering: isHovering))
|
|
.animation(.easeInOut(duration: HAButtonStylesConstants.animationDuration), value: isPressed)
|
|
.animation(.easeInOut(duration: HAButtonStylesConstants.animationDuration), value: isHovering)
|
|
#if !os(watchOS)
|
|
.onHover { hovering in
|
|
if isEnabled {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func scaleForState(isPressed: Bool, isHovering: Bool) -> CGFloat {
|
|
if isPressed {
|
|
return HAButtonStylesConstants.highlightedScale
|
|
}
|
|
|
|
if isHovering {
|
|
return HAButtonStylesConstants.hoverScale
|
|
}
|
|
|
|
return 1.0
|
|
}
|
|
}
|