iOS/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift
Copilot feaeb0071a
Implement consistent hover effect for all HAButtonStyle variants (#3966)
## 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>
2025-11-12 17:01:10 +00:00

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
}
}