diff --git a/Layers/Example/LayerExample.swift b/Layers/Example/LayerExample.swift index 877b1be..9aacb48 100644 --- a/Layers/Example/LayerExample.swift +++ b/Layers/Example/LayerExample.swift @@ -49,9 +49,7 @@ struct LayerExample: View { if !layers.getCurrentButtons()[1].isEmpty { LayerButton(text: Binding.constant(layers.getCurrentButtons()[1].keys.first ?? ""), icon: Binding.constant(layers.getCurrentButtons()[1].values.first ?? ""), - background: .blue, - foregroundColor: .orange - ) + background: .blue) { layers.next() } @@ -76,7 +74,6 @@ struct LayerExamplePreview: View { } } - #Preview() { ZStack { Color(.black.opacity(0.25)) diff --git a/Layers/Example/LayerExampleComponents.swift b/Layers/Example/LayerExampleComponents.swift index 106bd8e..8685b08 100644 --- a/Layers/Example/LayerExampleComponents.swift +++ b/Layers/Example/LayerExampleComponents.swift @@ -73,14 +73,14 @@ struct ExampleIcon: View { // MARK: - Example Headers struct ExampleHeader1: View { - @Namespace var namespace + @EnvironmentObject var namespaceWrapper: NamespaceWrapper var body: some View { HStack { ExampleIcon(icon: "questionmark") .matchedGeometryEffect( id: "layer.icon.left", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) FullWidthText(center: true) { @@ -91,21 +91,21 @@ struct ExampleHeader1: View { .transition(.scale(scale: 1.0)) .matchedGeometryEffect( id: "layer.header", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } ExampleIcon(icon: "xmark") .matchedGeometryEffect( id: "layer.icon.right", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } } struct ExampleHeader2: View { - @Namespace var namespace + @EnvironmentObject var namespaceWrapper: NamespaceWrapper var body: some View { HStack { @@ -117,28 +117,28 @@ struct ExampleHeader2: View { .transition(.scale(scale: 1.0)) .matchedGeometryEffect( id: "layer.header", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } ExampleIcon(icon: "xmark") .matchedGeometryEffect( id: "layer.icon.right", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } } struct ExampleHeader3: View { - @Namespace var namespace + @EnvironmentObject var namespaceWrapper: NamespaceWrapper var body: some View { HStack { ExampleIcon(icon: "questionmark") .matchedGeometryEffect( id: "layer.icon.left", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) FullWidthText(center: true) { @@ -149,14 +149,14 @@ struct ExampleHeader3: View { .transition(.scale(scale: 1.0)) .matchedGeometryEffect( id: "layer.header", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } ExampleIcon(icon: "xmark") .matchedGeometryEffect( id: "layer.icon.right", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } @@ -165,6 +165,8 @@ struct ExampleHeader3: View { // MARK: - Example Content struct ExampleContent1: View { + @EnvironmentObject var namespaceWrapper: NamespaceWrapper + var body: some View { VStack(spacing: 16) { HStack(alignment: .top) { @@ -201,7 +203,7 @@ struct ExampleContent1: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .matchedGeometryEffect( id: "layer.content.image.details", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) Image(.smart) @@ -211,7 +213,7 @@ struct ExampleContent1: View { .transition(.scale(scale: 1.0)) .matchedGeometryEffect( id: "layer.content.image", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) HStack(alignment: .top) { @@ -240,25 +242,29 @@ struct ExampleContent1: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .matchedGeometryEffect( id: "layer.content.recipient", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } } struct ExampleContent2: View { + @EnvironmentObject var namespaceWrapper: NamespaceWrapper + var body: some View { VStack(spacing: 16) { ExampleTextField(input: "", placeholder: "Something meaningful...", variation: .extraLarge) .matchedGeometryEffect( id: "layer.content.texfield", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } } struct ExampleContent3: View { + @EnvironmentObject var namespaceWrapper: NamespaceWrapper + var body: some View { VStack(spacing: 16) { HStack(alignment: .top) { @@ -336,7 +342,7 @@ struct ExampleContent3: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .matchedGeometryEffect( id: "layer.content.final", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } @@ -352,6 +358,8 @@ struct ExampleContent3: View { // MARK: - Example Buttons struct LayerButton: View { + @EnvironmentObject var namespaceWrapper: NamespaceWrapper + @Binding var text: String @Binding var icon: String @@ -391,7 +399,7 @@ struct LayerButton: View { .transition(.opacity) .matchedGeometryEffect( id: "layer.button.icon.\(id)", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } @@ -401,7 +409,7 @@ struct LayerButton: View { .transition(.scale(scale: 1.0)) .matchedGeometryEffect( id: "layer.button.text.\(id)", - in: LayerConstants.namespace + in: namespaceWrapper.namespace ) } } @@ -424,4 +432,3 @@ struct LayerButton: View { } } } - diff --git a/Layers/Source/Layer+Extensions.swift b/Layers/Source/Layer+Extensions.swift index d850cd8..4503e7e 100644 --- a/Layers/Source/Layer+Extensions.swift +++ b/Layers/Source/Layer+Extensions.swift @@ -8,33 +8,35 @@ import Foundation import SwiftUI -struct ScaleButtonStyle: ButtonStyle { - public init() {} - - public func makeBody(configuration: Self.Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.85 : 1) - .transition(.scale(scale: 1.0)) - } -} +// MARK: - Views +/// A view that horizontally aligns its content to fill the available width. +/// +/// The `FullWidthText` struct is a convenient way to horizontally align text or any other content to fill the entire +/// available width, optionally centering it within the space. struct FullWidthText: View { + /// The text or content to be displayed and aligned within the view. @State var text: AnyView + + /// A boolean value that determines whether the content should be centered within the available width. @State var center: Bool - public init( + /// Initializes a new instance of the `FullWidthText` view. + /// + /// - Parameters: + /// - center: A boolean value indicating whether the content should be centered within the available width. + /// - text: A closure that generates the text or content to be displayed within the view. + init( center: Bool = false, - @ViewBuilder text: @escaping () -> Content) where Content: View - { + @ViewBuilder text: @escaping () -> Content + ) where Content: View { self.center = center self.text = AnyView(text()) } var body: some View { HStack { - if center { - Spacer() - } + if center { Spacer() } text @@ -44,27 +46,46 @@ struct FullWidthText: View { } } -struct ContrastTextColor: ViewModifier { - var background: Color - var light: Color = .white - var dark: Color = .black - var foregroundColor: Color? = nil +// MARK: - Button Styles - func body(content: Content) -> some View { - if let fgColor = foregroundColor { - return content.foregroundColor(fgColor) - } else { - return content.foregroundColor(background.isDark ? light : dark) - } +/// A button style that scales its content when pressed. +/// +/// The `ScaleButtonStyle` struct provides a button style that scales down its content when pressed, giving a visual +/// feedback effect to indicate interaction. +public struct ScaleButtonStyle: ButtonStyle { + /// Initializes a new instance of the `ScaleButtonStyle`. + public init() {} + + /// Creates the view for the button's body. + /// + /// - Parameter configuration: The button's configuration. + /// - Returns: A modified view with the scaling effect applied. + public func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.85 : 1) + .transition(.scale(scale: 1.0)) } } -extension View { - func contrastTextColor(background: Color, light: Color = .white, dark: Color = .black, foregroundColor: Color? = nil) -> some View { - modifier(ContrastTextColor(background: background, light: light, dark: dark, foregroundColor: foregroundColor)) +public extension ButtonStyle where Self == ScaleButtonStyle { + /// A convenience property to apply the `ScaleButtonStyle` to a button. + /// + /// Use this property to apply the `ScaleButtonStyle` to a button without explicitly creating an instance + /// of the style. + /// + /// Example usage: + /// ``` + /// Button("Press Me") { + /// // Action to perform when the button is pressed + /// } + /// .buttonStyle(.scale) + /// ``` + static var scale: ScaleButtonStyle { + ScaleButtonStyle() } } +// MARK: - Modifiers extension Color { private enum Luminance { @@ -74,6 +95,7 @@ extension Color { static let threshold: CGFloat = 0.7 } + /// A boolean value indicating whether the color is considered dark based on its luminance. var isDark: Bool { var red: CGFloat = 0 var green: CGFloat = 0 @@ -93,8 +115,36 @@ extension Color { } } -extension ButtonStyle where Self == ScaleButtonStyle { - static var scale: ScaleButtonStyle { - ScaleButtonStyle() +/// A view modifier for setting the text color with contrast based on the background color. +struct ContrastTextColor: ViewModifier { + var background: Color + var light: Color = .white + var dark: Color = .black + var foregroundColor: Color? = nil + + /// Applies text color contrast based on the background color. + /// + /// - Parameter content: The content to which the text color contrast is applied. + /// - Returns: A modified view with the appropriate text color. + func body(content: Content) -> some View { + if let fgColor = foregroundColor { + return content.foregroundColor(fgColor) + } else { + return content.foregroundColor(background.isDark ? light : dark) + } + } +} + +extension View { + /// Applies text color contrast based on the background color. + /// + /// - Parameters: + /// - background: The background color used to determine text color contrast. + /// - light: The text color to use when the background is considered dark. + /// - dark: The text color to use when the background is considered light. + /// - foregroundColor: An optional text color to use, which takes precedence over automatic contrast calculation. + /// - Returns: A modified view with the appropriate text color contrast applied. + func contrastTextColor(background: Color, light: Color = .white, dark: Color = .black, foregroundColor: Color? = nil) -> some View { + modifier(ContrastTextColor(background: background, light: light, dark: dark, foregroundColor: foregroundColor)) } } diff --git a/Layers/Source/Layer.swift b/Layers/Source/Layer.swift index 4fed542..688c79c 100644 --- a/Layers/Source/Layer.swift +++ b/Layers/Source/Layer.swift @@ -10,22 +10,48 @@ import Observation import SwiftData import SwiftUI -// MARK: - Constants +// MARK: - Namespace -public enum LayerConstants { - static let namespace: Namespace.ID = Namespace().wrappedValue +class NamespaceWrapper: ObservableObject { + var namespace: Namespace.ID + + init(_ namespace: Namespace.ID) { + self.namespace = namespace + } } // MARK: - Model +/// A class representing a model for managing layered content. +/// +/// This class is responsible for managing the state and behavior of layered content, such as headers, contents, +/// and buttons. It allows navigation between different layers of content and provides methods to access the +/// current header, content, and buttons. @Observable class LayerModel { + /// The current index indicating the active layer. var index: Int - var max: Int - var headers: [Int: AnyView] - var contents: [Int: AnyView] - var buttons: [Int: [[String: String]]] + /// The maximum number of layers available. + let max: Int + + /// A dictionary mapping layer indices to their corresponding header views. + let headers: [Int: AnyView] + + /// A dictionary mapping layer indices to their corresponding content views. + let contents: [Int: AnyView] + + /// A dictionary mapping layer indices to an array of button configurations. + let buttons: [Int: [[String: String]]] + + /// Initializes a new instance of the `LayerModel` class. + /// + /// - Parameters: + /// - index: The initial index of the active layer. + /// - max: The maximum number of layers available. + /// - headers: A dictionary of header views mapped to their respective layer indices. + /// - contents: A dictionary of content views mapped to their respective layer indices. + /// - buttons: A dictionary of button configurations mapped to their respective layer indices. internal init( index: Int, max: Int, @@ -40,12 +66,14 @@ class LayerModel { self.buttons = buttons } + /// Moves to the next layer. func next() { withAnimation(.snappy) { index = (index + 1) % max } } + /// Moves to the previous layer. func previous() { withAnimation(.snappy) { if index == 0 { @@ -56,20 +84,32 @@ class LayerModel { } } + /// Sets the active layer to the specified index. + /// + /// - Parameter index: The index of the layer to set as active. func set(index: Int) { withAnimation(.snappy) { self.index = index } } + /// Retrieves the current header view for the active layer. + /// + /// - Returns: The current header view or an empty view if none is available. func getCurrentHeader() -> AnyView { return headers[index] ?? AnyView(EmptyView()) } + /// Retrieves the current content view for the active layer. + /// + /// - Returns: The current content view or an empty view if none is available. func getCurrentContent() -> AnyView { return contents[index] ?? AnyView(EmptyView()) } + /// Retrieves the button configurations for the active layer. + /// + /// - Returns: An array of button configurations for the active layer or an empty array if none is available. func getCurrentButtons() -> [[String: String]] { return buttons[index] ?? [["": ""]] } @@ -77,14 +117,24 @@ class LayerModel { // MARK: - View +/// A view representing a layered content container. +/// +/// The `Layer` struct is used to create a layered content container. It allows you to wrap any content in a +/// visually distinct layer with customizable padding and background styling. struct Layer: View { - @Namespace private var layer - - var content: AnyView - - public init( - @ViewBuilder content: @escaping () -> Content) where Content: View - { + /// The namespace used for matched geometry effects within this layer. + @Namespace private var namespace + + /// The content to be displayed within the layer. + let content: AnyView + + /// Initializes a new instance of the `Layer` struct. + /// + /// - Parameters: + /// - content: A closure that generates the content to be displayed within the layer. + init( + @ViewBuilder content: @escaping () -> Content + ) where Content: View { self.content = AnyView(content()) } @@ -102,14 +152,14 @@ struct Layer: View { Color(.systemBackground) .matchedGeometryEffect( id: "layer.background", - in: LayerConstants.namespace + in: namespace ) ) .mask { RoundedRectangle(cornerRadius: 32, style: .continuous) .matchedGeometryEffect( id: "layer.mask", - in: LayerConstants.namespace + in: namespace ) } } @@ -122,5 +172,6 @@ struct Layer: View { trailing: 16 ) ) + .environmentObject(NamespaceWrapper(namespace)) } }