diff --git a/Sources/LCEssentials/SwiftUI/LCENavigationView.swift b/Sources/LCEssentials/SwiftUI/LCENavigationView.swift index 44bf3dc..afb9953 100644 --- a/Sources/LCEssentials/SwiftUI/LCENavigationView.swift +++ b/Sources/LCEssentials/SwiftUI/LCENavigationView.swift @@ -42,13 +42,46 @@ class LCENavigationState: ObservableObject { /// A boolean value that controls the visibility of the navigation bar. @Published var hideNavigationBar: Bool = false - + /// The title view of the navigation bar. @Published var title: (any View) = Text("") /// The subtitle view of the navigation bar. @Published var subTitle: (any View) = Text("") /// The background color of the navigation bar. @Published var navigationBarBackgroundColor: Color = .clear + + /// Whether a left button was actually configured via `setLeftButton`. + @Published var hasLeftButton: Bool = false + /// Whether a right button was actually configured via `setRightButton`. + @Published var hasRightButton: Bool = false + /// Whether buttons should opt into the system Liquid Glass button style (iOS 26+). Off by default. + @Published var useGlassButtons: Bool = false +} + +/// `PreferenceKey` used to measure the widest side button so the title can be padded symmetrically and stay centered without overlapping either button. +@available(iOS 15, *) +private struct LCENavButtonWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat { 0 } + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +@available(iOS 15, *) +private extension View { + /// Applies `.glass` button style on iOS 26+ when enabled, otherwise falls back to `.plain` — the navigation bar is not designed around glass, but stays opt-in ready. + @ViewBuilder + func lce_applyGlassIfEnabled(_ enabled: Bool) -> some View { + if enabled { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.plain) + } + } else { + self.buttonStyle(.plain) + } + } } /// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar. @@ -60,7 +93,10 @@ public struct LCENavigationView: View { /// The content view displayed below the navigation bar. let content: Content - + + /// The measured width of the widest side button, used to pad the title so it never overlaps either button. + @State private var maxButtonWidth: CGFloat = 0 + /// Initializes a new `LCENavigationView` instance. /// - Parameters: /// - title: The title view for the navigation bar. Defaults to an empty `Text`. @@ -92,20 +128,31 @@ public struct LCENavigationView: View { /// The private `NavigationBarView` that lays out the navigation bar components. private var NavigationBarView: some View { - HStack { - NavLeftButton - Spacer() + ZStack { TitleView - Spacer() - NavRightButton + .padding(.horizontal, maxButtonWidth) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity) + + HStack { + if state.hasLeftButton { + NavLeftButton + } + Spacer() + if state.hasRightButton { + NavRightButton + } + } } .font(.headline) .padding() .background { state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top) } + .onPreferenceChange(LCENavButtonWidthPreferenceKey.self) { maxButtonWidth = $0 } } - + /// The private `TitleView` that displays the title and subtitle. private var TitleView: some View { VStack { @@ -115,8 +162,8 @@ public struct LCENavigationView: View { } } } - - /// The private `NavLeftButton` view. + + /// The private `NavLeftButton` view. Only rendered when `setLeftButton` was actually called — never a hidden placeholder. private var NavLeftButton: some View { Button(action: state.leftButtonAction) { HStack { @@ -126,9 +173,15 @@ public struct LCENavigationView: View { state.leftButtonText } } + .lce_applyGlassIfEnabled(state.useGlassButtons) + .background( + GeometryReader { proxy in + Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width) + } + ) } - - /// The private `NavRightButton` view. + + /// The private `NavRightButton` view. Only rendered when `setRightButton` was actually called — never a hidden placeholder. private var NavRightButton: some View { Button(action: state.rightButtonAction) { HStack { @@ -138,6 +191,12 @@ public struct LCENavigationView: View { } } } + .lce_applyGlassIfEnabled(state.useGlassButtons) + .background( + GeometryReader { proxy in + Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width) + } + ) } /// Sets the configuration for the right button of the navigation bar. @@ -158,14 +217,8 @@ public struct LCENavigationView: View { } state.rightButtonText = text state.rightButtonAction = action - - if let string = state.leftButtonText.string, string.isEmpty { - state.leftButtonText = text.foregroundColor(.clear) - } - if state.leftButtonImage == nil { - state.leftButtonImage = image?.foregroundColor(.clear) as? AnyView - } - + state.hasRightButton = true + return self } @@ -187,14 +240,17 @@ public struct LCENavigationView: View { } state.leftButtonText = text state.leftButtonAction = action - - if let string = state.rightButtonText.string, string.isEmpty { - state.rightButtonText = text.foregroundColor(.clear) - } - if state.rightButtonImage == nil { - state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView - } - + state.hasLeftButton = true + + return self + } + + /// Opts the navigation bar's buttons into the system Liquid Glass `.glass` button style on iOS 26+. + /// The navigation bar is designed as a plain, non-glass component by default; call this to enable glass explicitly. + /// - Parameter enabled: Whether buttons should use the glass style. + /// - Returns: The `LCENavigationView` instance for chaining. + public func setGlassButtonsEnabled(_ enabled: Bool) -> LCENavigationView { + state.useGlassButtons = enabled return self } @@ -229,101 +285,6 @@ public struct LCENavigationView: View { } } -/// Extension to `FormatStyle` to format any value as a string. -@available(iOS 15.0, *) -extension FormatStyle { - /// Formats an input value if it matches the `FormatInput` type. - /// - Parameter value: The value to format as `Any`. - /// - Returns: The formatted output, or `nil` if the value type does not match. - func format(any value: Any) -> FormatOutput? { - if let v = value as? FormatInput { - return format(v) - } - return nil - } -} - -/// Extension to `LocalizedStringKey` to resolve localized strings. -@available(iOS 15.0, *) -extension LocalizedStringKey { - /// Resolves the localized string key into a `String`. - /// - Returns: The resolved string, or `nil` if resolution fails. - var resolved: String? { - let mirror = Mirror(reflecting: self) - guard let key = mirror.descendant("key") as? String else { - return nil - } - - guard let args = mirror.descendant("arguments") as? [Any] else { - return nil - } - - let values = args.map { arg -> Any? in - let mirror = Mirror(reflecting: arg) - if let value = mirror.descendant("storage", "value", ".0") { - return value - } - - guard let format = mirror.descendant("storage", "formatStyleValue", "format") as? any FormatStyle, - let input = mirror.descendant("storage", "formatStyleValue", "input") else { - return nil - } - - return format.format(any: input) - } - - let va = values.compactMap { arg -> CVarArg? in - switch arg { - case let i as Int: return i - case let i as Int64: return i - case let i as Int8: return i - case let i as Int16: return i - case let i as Int32: return i - case let u as UInt: return u - case let u as UInt64: return u - case let u as UInt8: return u - case let u as UInt16: return u - case let u as UInt32: return u - case let f as Float: return f - case let f as CGFloat: return f - case let d as Double: return d - case let o as NSObject: return o - default: return nil - } - } - - if va.count != values.count { - return nil - } - - return String.localizedStringWithFormat(key, va) - } -} - -/// Extension to `Text` to retrieve its string content. -@available(iOS 15.0, *) -extension Text { - /// Returns the string representation of the `Text` view. - /// - Returns: The string content, or `nil` if it cannot be extracted. - var string: String? { - let mirror = Mirror(reflecting: self) - if let s = mirror.descendant("storage", "verbatim") as? String { - return s - } else if let attrStr = mirror.descendant("storage", "anyTextStorage", "str") as? AttributedString { - return String(attrStr.characters) - } else if let key = mirror.descendant("storage", "anyTextStorage", "key") as? LocalizedStringKey { - return key.resolved - } else if let format = mirror.descendant("storage", "anyTextStorage", "storage", "format") as? any FormatStyle, - let input = mirror.descendant("storage", "anyTextStorage", "storage", "input") { - return format.format(any: input) as? String - } else if let formatter = mirror.descendant("storage", "anyTextStorage", "formatter") as? Formatter, - let object = mirror.descendant("storage", "anyTextStorage", "object") { - return formatter.string(for: object) - } - return nil - } -} - //@available(iOS 15.0, *) //struct LCENavigationView_Previews: PreviewProvider { // static var previews: some View {