[fix] LCENavigationView: remove phantom glass button, center title safely #11

Merged
daniel-loverde merged 1 commits from fix/navigation/glass-duplicate-button into main 2026-09-01 16:26:03 -03:00
Showing only changes of commit 72a5b59c44 - Show all commits

View File

@@ -49,6 +49,39 @@ class LCENavigationState: ObservableObject {
@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.
@@ -61,6 +94,9 @@ public struct LCENavigationView<Content: View>: 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,18 +128,29 @@ public struct LCENavigationView<Content: View>: 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.
@@ -116,7 +163,7 @@ public struct LCENavigationView<Content: View>: 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<Content: View>: 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<Content: View>: 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,13 +217,7 @@ public struct LCENavigationView<Content: View>: 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<Content: View>: View {
}
state.leftButtonText = text
state.leftButtonAction = action
state.hasLeftButton = true
if let string = state.rightButtonText.string, string.isEmpty {
state.rightButtonText = text.foregroundColor(.clear)
}
if state.rightButtonImage == nil {
state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView
}
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<Content: View>: 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 {