303 lines
8.2 KiB
Swift
Executable file
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
|