swift-buttons/Sources/LilithButtons/FloatingActionButton.swift
2026-02-16 09:29:42 -08:00

303 lines
8.2 KiB
Swift
Executable file

// FloatingActionButton.swift
// iOS UI Components - Button Components
//
// Floating action button (FAB) for primary screen actions
import SwiftUI
import LilithDesignTokens
/// Floating Action Button (FAB)
///
/// Use for the primary action on a screen. Floats above content.
/// Typically positioned in bottom-right corner.
///
/// Example:
/// ```swift
/// FloatingActionButton(icon: Image(systemName: "plus")) {
/// showCreateSheet()
/// }
/// .fabPosition(.bottomTrailing)
/// ```
public struct FloatingActionButton: View {
// MARK: - Size
public enum Size {
case regular // 56pt
case large // 72pt
var dimension: CGFloat {
switch self {
case .regular: return 56
case .large: return 72
}
}
var iconSize: CGFloat {
switch self {
case .regular: return AppTypography.FontSize.xl
case .large: return AppTypography.FontSize.xl2
}
}
}
// MARK: - Properties
private let icon: Image
private let label: String
private let size: Size
private let action: () -> Void
// MARK: - State
@State private var isPressed = false
// MARK: - Initialization
/// Create a floating action button
/// - Parameters:
/// - icon: SF Symbol or custom image
/// - label: Accessibility label
/// - size: FAB size (regular or large)
/// - action: Action to perform on tap
public init(
icon: Image,
label: String = "Primary action",
size: Size = .regular,
action: @escaping () -> Void
) {
self.icon = icon
self.label = label
self.size = size
self.action = action
}
// MARK: - Body
public var body: some View {
Button(action: handleTap) {
ZStack {
// Background circle
Circle()
.fill(AppColors.primary)
.frame(width: size.dimension, height: size.dimension)
.shadow(AppShadows.floating)
// Icon
icon
.font(.system(size: size.iconSize, weight: .semibold))
.foregroundColor(.white)
}
.scaleEffect(isPressed ? 0.9 : 1.0)
.animation(AppAnimations.spring, value: isPressed)
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(label)
.accessibilityHint("Double tap to activate")
.accessibilityAddTraits(.isButton)
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
}
// MARK: - Methods
private func handleTap() {
// Strong haptic feedback for primary action
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.impactOccurred()
#endif
action()
}
}
// MARK: - View Extension (FAB Positioning)
extension View {
/// Position a FAB on the screen
/// - Parameters:
/// - alignment: Screen alignment (default: .bottomTrailing)
/// - padding: Distance from edges (default: 16pt)
public func fabPosition(
_ alignment: Alignment = .bottomTrailing,
padding: CGFloat = AppSpacing.base
) -> some View {
ZStack(alignment: alignment) {
self
// FAB placeholder (will be replaced with actual FAB)
Color.clear
.frame(width: 0, height: 0)
}
.padding(padding)
}
}
// MARK: - Extended FAB (with label)
/// Extended Floating Action Button with text label
///
/// Use when you need to provide more context for the primary action.
///
/// Example:
/// ```swift
/// ExtendedFAB(icon: Image(systemName: "plus"), text: "New Post") {
/// showCreatePost()
/// }
/// ```
public struct ExtendedFAB: View {
// MARK: - Properties
private let icon: Image
private let text: String
private let action: () -> Void
// MARK: - State
@State private var isPressed = false
// MARK: - Initialization
public init(
icon: Image,
text: String,
action: @escaping () -> Void
) {
self.icon = icon
self.text = text
self.action = action
}
// MARK: - Body
public var body: some View {
Button(action: handleTap) {
HStack(spacing: AppSpacing.md) {
icon
.font(.system(size: AppTypography.FontSize.lg, weight: .semibold))
Text(text)
.font(AppTypography.body(weight: .bold))
}
.foregroundColor(.white)
.padding(.horizontal, AppSpacing.xl)
.padding(.vertical, AppSpacing.base)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.full))
.shadow(AppShadows.floating)
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(AppAnimations.spring, value: isPressed)
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(text)
.accessibilityHint("Double tap to activate")
.accessibilityAddTraits(.isButton)
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
isPressed = pressing
}, perform: {})
}
// MARK: - Methods
private func handleTap() {
#if canImport(UIKit)
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.impactOccurred()
#endif
action()
}
}
// MARK: - Preview Provider
#if DEBUG
struct FloatingActionButton_Previews: PreviewProvider {
static var previews: some View {
Group {
// Regular FAB
ZStack {
Color.gray.opacity(0.1)
.ignoresSafeArea()
VStack {
Spacer()
HStack {
Spacer()
FloatingActionButton(
icon: Image(systemName: "plus"),
label: "Create new"
) {
print("FAB tapped")
}
.padding()
}
}
}
.previewDisplayName("Regular FAB")
// Large FAB
ZStack {
Color.gray.opacity(0.1)
.ignoresSafeArea()
VStack {
Spacer()
HStack {
Spacer()
FloatingActionButton(
icon: Image(systemName: "camera.fill"),
label: "Take photo",
size: .large
) {
print("Large FAB tapped")
}
.padding()
}
}
}
.previewDisplayName("Large FAB")
// Extended FAB
ZStack {
Color.gray.opacity(0.1)
.ignoresSafeArea()
VStack {
Spacer()
HStack {
Spacer()
ExtendedFAB(
icon: Image(systemName: "plus"),
text: "New Post"
) {
print("Extended FAB tapped")
}
.padding()
}
}
}
.previewDisplayName("Extended FAB")
// Multiple FAB states
VStack(spacing: AppSpacing.xl2) {
FloatingActionButton(icon: Image(systemName: "plus")) {}
FloatingActionButton(icon: Image(systemName: "camera.fill")) {}
FloatingActionButton(icon: Image(systemName: "mic.fill")) {}
FloatingActionButton(icon: Image(systemName: "pencil")) {}
}
.padding()
.previewLayout(.sizeThatFits)
.background(AppColors.background)
.previewDisplayName("FAB Icons")
}
}
}
#endif