diff --git a/Sources/Luminare/Components/DividedVStack.swift b/Sources/Luminare/Components/Auxiliary/DividedVStack.swift similarity index 86% rename from Sources/Luminare/Components/DividedVStack.swift rename to Sources/Luminare/Components/Auxiliary/DividedVStack.swift index 0826a20..21b454b 100644 --- a/Sources/Luminare/Components/DividedVStack.swift +++ b/Sources/Luminare/Components/Auxiliary/DividedVStack.swift @@ -4,21 +4,27 @@ // // Created by Kai Azim on 2024-04-02. // -// Thank you https://movingparts.io/variadic-views-in-swiftui +// Thanks to https://movingparts.io/variadic-views-in-swiftui import SwiftUI -public struct DividedVStack: View { +public struct DividedVStack: View where Content: View { let spacing: CGFloat? let applyMaskToItems: Bool let showDividers: Bool - var content: Content + + @ViewBuilder let content: () -> Content - public init(spacing: CGFloat? = nil, applyMaskToItems: Bool = true, showDividers: Bool = true, @ViewBuilder content: () -> Content) { + public init( + spacing: CGFloat? = nil, + applyMaskToItems: Bool = true, + showDividers: Bool = true, + @ViewBuilder content: @escaping () -> Content + ) { self.spacing = spacing self.applyMaskToItems = applyMaskToItems self.showDividers = showDividers - self.content = content() + self.content = content } public var body: some View { @@ -29,7 +35,7 @@ public struct DividedVStack: View { showDividers: showDividers ) ) { - content + content() } } } @@ -38,7 +44,6 @@ struct DividedVStackLayout: _VariadicView_UnaryViewRoot { let spacing: CGFloat let applyMaskToItems: Bool let showDividers: Bool - let innerPadding: CGFloat = 4 init(spacing: CGFloat?, applyMaskToItems: Bool, showDividers: Bool) { @@ -88,8 +93,8 @@ public struct LuminareCroppedSectionItem: ViewModifier { let innerPadding: CGFloat = 4 let innerCornerRadius: CGFloat = 2 - let isFirstChild: Bool - let isLastChild: Bool + private let isFirstChild: Bool + private let isLastChild: Bool public init(isFirstChild: Bool, isLastChild: Bool) { self.isFirstChild = isFirstChild @@ -134,3 +139,14 @@ public struct LuminareCroppedSectionItem: ViewModifier { } } } + +#Preview { + LuminareSection { + DividedVStack { + ForEach(37..<43) { num in + Text("\(num)") + } + } + } + .padding() +} diff --git a/Sources/Luminare/Components/LuminareButtonStyle.swift b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyle.swift similarity index 85% rename from Sources/Luminare/Components/LuminareButtonStyle.swift rename to Sources/Luminare/Components/Auxiliary/LuminareButtonStyle.swift index 95d2c90..246a629 100644 --- a/Sources/Luminare/Components/LuminareButtonStyle.swift +++ b/Sources/Luminare/Components/Auxiliary/LuminareButtonStyle.swift @@ -9,9 +9,11 @@ import SwiftUI public struct LuminareButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool + let innerCornerRadius: CGFloat = 2 let elementMinHeight: CGFloat = 34 - @State var isHovering: Bool = false + + @State private var isHovering: Bool = false public init() {} @@ -45,8 +47,10 @@ public struct LuminareButtonStyle: ButtonStyle { public struct LuminareDestructiveButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool + let innerCornerRadius: CGFloat = 2 let elementMinHeight: CGFloat = 34 + @State var isHovering: Bool = false public init() {} @@ -81,8 +85,10 @@ public struct LuminareDestructiveButtonStyle: ButtonStyle { public struct LuminareCosmeticButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool + let innerCornerRadius: CGFloat = 2 let elementMinHeight: CGFloat = 34 + @State var isHovering: Bool = false let icon: Image @@ -126,14 +132,18 @@ public struct LuminareCosmeticButtonStyle: ButtonStyle { public struct LuminareCompactButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled: Bool + let elementMinHeight: CGFloat = 34 let elementExtraMinHeight: CGFloat = 25 let extraCompact: Bool + let borderlessWhileNotHovering: Bool + @State var isHovering: Bool = false let cornerRadius: CGFloat = 8 - public init(extraCompact: Bool = false) { + public init(extraCompact: Bool = false, borderlessWhileNotHovering: Bool = false) { self.extraCompact = extraCompact + self.borderlessWhileNotHovering = borderlessWhileNotHovering } public func makeBody(configuration: Configuration) -> some View { @@ -141,10 +151,7 @@ public struct LuminareCompactButtonStyle: ButtonStyle { .padding(.horizontal, extraCompact ? 0 : 12) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(backgroundForState(isPressed: configuration.isPressed)) - .background { - RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(.quaternary, lineWidth: 1) - } + .background(border()) .fixedSize(horizontal: extraCompact, vertical: extraCompact) .clipShape(.rect(cornerRadius: cornerRadius)) .onHover { hover in @@ -156,15 +163,35 @@ public struct LuminareCompactButtonStyle: ButtonStyle { .frame(minHeight: extraCompact ? elementExtraMinHeight : elementMinHeight) .opacity(isEnabled ? 1 : 0.5) } + + @ViewBuilder private func border() -> some View { + Group { + if isHovering || !borderlessWhileNotHovering { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(.quaternary, lineWidth: 1) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(.clear, lineWidth: 1) + } + } + } - private func backgroundForState(isPressed: Bool) -> some View { + @ViewBuilder private func backgroundForState(isPressed: Bool) -> some View { Group { if isPressed { Rectangle().foregroundStyle(.quaternary) } else if isHovering { - Rectangle().foregroundStyle(.quaternary.opacity(0.7)) + if borderlessWhileNotHovering { + Rectangle().foregroundStyle(.quinary) + } else { + Rectangle().foregroundStyle(.quaternary.opacity(0.7)) + } } else { - Rectangle().foregroundStyle(.quinary) + if borderlessWhileNotHovering { + Rectangle().foregroundStyle(.clear) + } else { + Rectangle().foregroundStyle(.quinary) + } } } } diff --git a/Sources/Luminare/Components/Auxiliary/LuminareInfoView.swift b/Sources/Luminare/Components/Auxiliary/LuminareInfoView.swift new file mode 100644 index 0000000..ac963cf --- /dev/null +++ b/Sources/Luminare/Components/Auxiliary/LuminareInfoView.swift @@ -0,0 +1,101 @@ +// +// LuminareInfoView.swift +// +// +// Created by Kai Azim on 2024-06-02. +// + +import SwiftUI + +public struct LuminareInfoView: View where Content: View { + let color: Color + let arrowEdge: Edge + + @ViewBuilder private let content: () -> Content + + @State private var isShowingDescription: Bool = false + @State private var isHovering: Bool = false + @State private var hoverTimer: Timer? + + public init( + color: Color = .accentColor, + arrowEdge: Edge = .bottom, + @ViewBuilder content: @escaping () -> Content + ) { + self.color = color + self.arrowEdge = arrowEdge + self.content = content + } + + public init( + _ key: LocalizedStringKey, + color: Color = .accentColor, + arrowEdge: Edge = .bottom + ) where Content == Text { + self.init(color: color, arrowEdge: arrowEdge) { + Text(key) + } + } + + public init() where Content == EmptyView { + self.init { + EmptyView() + } + } + + public var body: some View { + VStack { + Circle() + .foregroundStyle(color) + .frame(width: 4, height: 4) + .padding(.leading, 4) + .padding(12) + .contentShape(.circle) + .padding(-12) + .onHover { hovering in + isHovering = hovering + + if isHovering { + hoverTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in + isShowingDescription = true + } + } else { + hoverTimer?.invalidate() + isShowingDescription = false + } + } + + .popover(isPresented: $isShowingDescription, arrowEdge: arrowEdge) { + content() + .multilineTextAlignment(.center) + } + + Spacer() + } + } +} + +#Preview { + LuminareSection { + LuminareCompose { + } label: { + Text("Pops to bottom") + } info: { + LuminareInfoView { + Text("An info description") + .padding() + } + } + + LuminareCompose { + } label: { + Text("Pops to trailing") + } info: { + LuminareInfoView(color: .violet, arrowEdge: .trailing) { + Text("An info description") + .padding() + } + } + } + .padding() +} diff --git a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift index 27be997..394ec70 100644 --- a/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorHueSliderView.swift @@ -14,7 +14,7 @@ struct ColorHueSliderView: View { @State private var selectionCornerRadius: CGFloat = 0 @State private var selectionWidth: CGFloat = 0 - // Gradient for the color spectrum slider + // gradient for the color spectrum slider private let colorSpectrumGradient = Gradient( colors: stride(from: 0.0, through: 1.0, by: 0.01) .map { @@ -109,3 +109,10 @@ struct ColorHueSliderView: View { return 15 * edgeFactor } } + +#Preview { + LuminareSection { + ColorHueSliderView(selectedColor: .constant(.accentColor)) + } + .padding() +} diff --git a/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift b/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift index 1146637..b2a01c1 100644 --- a/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorPickerModalView.swift @@ -7,17 +7,26 @@ import SwiftUI -// View for the color popup as a whole -struct ColorPickerModalView: View { +public typealias RGBColorNames = ( + red: R, + green: G, + blue: B +) + + +// view for the color popup as a whole +struct ColorPickerModalView: View where R: View, G: View, B: View { + typealias ColorNames = RGBColorNames + @Binding var color: Color @Binding var hexColor: String @State private var redComponent: Double = 0 @State private var greenComponent: Double = 0 @State private var blueComponent: Double = 0 - let colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey) + let colorNames: ColorNames - // Main view containing all components of the color picker + // main view containing all components of the color picker var body: some View { Group { LuminareSection(disablePadding: true, showDividers: false) { @@ -57,34 +66,40 @@ struct ColorPickerModalView: View { } } - // View for RGB input fields + // view for RGB input fields private var RGBInputFields: some View { HStack(spacing: 8) { - RGBInputField(label: colorNames.red, value: $redComponent) - .onChange(of: redComponent) { _ in - setColor(updateColorFromRGB()) - } - - RGBInputField(label: colorNames.green, value: $greenComponent) - .onChange(of: greenComponent) { _ in - setColor(updateColorFromRGB()) - } - - RGBInputField(label: colorNames.blue, value: $blueComponent) - .onChange(of: blueComponent) { _ in - setColor(updateColorFromRGB()) - } + RGBInputField(value: $redComponent) { + colorNames.red + } + .onChange(of: redComponent) { _ in + setColor(updateColorFromRGB()) + } + + RGBInputField(value: $greenComponent) { + colorNames.green + } + .onChange(of: greenComponent) { _ in + setColor(updateColorFromRGB()) + } + + RGBInputField(value: $blueComponent) { + colorNames.blue + } + .onChange(of: blueComponent) { _ in + setColor(updateColorFromRGB()) + } } } - // Set the color based on the source of change + // set the color based on the source of change private func setColor(_ newColor: Color) { withAnimation(LuminareConstants.fastAnimation) { color = newColor } } - // Update the color from RGB components + // update the color from RGB components private func updateColorFromRGB() -> Color { Color( red: redComponent / 255.0, @@ -93,7 +108,7 @@ struct ColorPickerModalView: View { ) } - // Update components when the color changes + // update components when the color changes private func updateComponents(newValue: Color) { hexColor = newValue.toHex() let rgb = newValue.toRGB() @@ -102,3 +117,18 @@ struct ColorPickerModalView: View { blueComponent = rgb.blue } } + +#Preview { + LuminareSection { + ColorPickerModalView( + color: .constant(.accentColor), + hexColor: .constant("ffffff"), + colorNames: ( + red: Text("Red"), + green: Text("Green"), + blue: Text("Blue") + )) + } + .padding() + .frame(width: 300, height: 375) +} diff --git a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift index f145bc7..3a155a6 100644 --- a/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift +++ b/Sources/Luminare/Components/Color Picker/ColorSaturationBrightnessView.swift @@ -7,7 +7,7 @@ import SwiftUI -// View for adjusting the lightness of a selected color +// view for adjusting the lightness of a selected color struct ColorSaturationBrightnessView: View { @Binding var selectedColor: Color @@ -74,12 +74,12 @@ struct ColorSaturationBrightnessView: View { } } - // Update the position of the circle based on user interaction + // update the position of the circle based on user interaction private func updateColor(_ location: CGPoint, _ viewSize: CGSize) { let adjustedX = max(0, min(location.x, viewSize.width)) let adjustedY = max(0, min(location.y, viewSize.height)) - // Only adjust brightness if dragging, to avoid overwriting with white or black + // only adjust brightness if dragging, to avoid overwriting with white or black if isDragging { let saturation = (adjustedX / viewSize.width) let brightness = 1 - (adjustedY / viewSize.height) @@ -96,7 +96,7 @@ struct ColorSaturationBrightnessView: View { } } - // Initialize the position of the circle based on the current color + // initialize the position of the circle based on the current color private func updateCirclePosition(_ viewSize: CGSize) { let hsb = selectedColor.toHSB() @@ -143,3 +143,10 @@ struct ColorPickerCircle: View { .animation(LuminareConstants.animation, value: [isHovering, isDragging]) } } + +#Preview { + LuminareSection { + ColorSaturationBrightnessView(selectedColor: .constant(.accentColor)) + } + .padding() +} diff --git a/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift b/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift index fb1aa1c..b5fc08d 100644 --- a/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift +++ b/Sources/Luminare/Components/Color Picker/LuminareColorPicker.swift @@ -7,22 +7,38 @@ import SwiftUI -public struct LuminareColorPicker: View { +public struct LuminareColorPicker: View +where R: View, G: View, B: View, F: ParseableFormatStyle, F.FormatInput == String, F.FormatOutput == String { + public typealias ColorNames = RGBColorNames + @Binding var currentColor: Color @State private var text: String @State private var showColorPicker = false - let colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey) - let formatStrategy: StringFormatStyle.HexStrategy + private let colorNames: ColorNames + private let format: F public init( - color: Binding, colorNames: (red: LocalizedStringKey, green: LocalizedStringKey, blue: LocalizedStringKey), - formatStrategy: StringFormatStyle.HexStrategy = .uppercasedWithWell + color: Binding, + colorNames: ColorNames, + format: F ) { self._currentColor = color self._text = State(initialValue: color.wrappedValue.toHex()) self.colorNames = colorNames - self.formatStrategy = formatStrategy + self.format = format + } + + public init( + color: Binding, + colorNames: ColorNames, + parseStrategy: StringFormatStyle.Strategy = .hex(.lowercasedWithWell) + ) where F == StringFormatStyle { + self.init( + color: color, + colorNames: colorNames, + format: .init(parseStrategy: parseStrategy) + ) } public var body: some View { @@ -30,16 +46,17 @@ public struct LuminareColorPicker: View { LuminareTextField( "Hex Color", value: .init($text), - format: StringFormatStyle(parseStrategy: .hex(formatStrategy)), - onSubmit: { - if let newColor = Color(hex: text) { - currentColor = newColor - text = newColor.toHex() - } else { - text = currentColor.toHex() // revert to last valid color - } - } + format: format ) + .onSubmit { + if let newColor = Color(hex: text) { + currentColor = newColor + text = newColor.toHex() + } else { + // revert to last valid color + text = currentColor.toHex() + } + } .modifier(LuminareBordered()) Button { @@ -62,3 +79,18 @@ public struct LuminareColorPicker: View { } } } + +// preview this as app to show the modal panel +#Preview { + LuminareColorPicker( + color: .constant(.accentColor), + colorNames: ( + red: Text("Red"), + green: Text("Green"), + blue: Text("Blue") + ), + parseStrategy: .hex(.custom(true, "$")) + ) + .monospaced() + .padding() +} diff --git a/Sources/Luminare/Components/Color Picker/RGBInputField.swift b/Sources/Luminare/Components/Color Picker/RGBInputField.swift index 5adb191..6a04966 100644 --- a/Sources/Luminare/Components/Color Picker/RGBInputField.swift +++ b/Sources/Luminare/Components/Color Picker/RGBInputField.swift @@ -7,14 +7,14 @@ import SwiftUI -// Custom input field for RGB values -struct RGBInputField: View { - var label: LocalizedStringKey +// custom input field for RGB values +struct RGBInputField