Compare commits
3 Commits
docs/readm
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5067212a | |||
|
|
72a5b59c44 | ||
|
|
d678965154 |
@@ -42,13 +42,46 @@ class LCENavigationState: ObservableObject {
|
|||||||
|
|
||||||
/// A boolean value that controls the visibility of the navigation bar.
|
/// A boolean value that controls the visibility of the navigation bar.
|
||||||
@Published var hideNavigationBar: Bool = false
|
@Published var hideNavigationBar: Bool = false
|
||||||
|
|
||||||
/// The title view of the navigation bar.
|
/// The title view of the navigation bar.
|
||||||
@Published var title: (any View) = Text("")
|
@Published var title: (any View) = Text("")
|
||||||
/// The subtitle view of the navigation bar.
|
/// The subtitle view of the navigation bar.
|
||||||
@Published var subTitle: (any View) = Text("")
|
@Published var subTitle: (any View) = Text("")
|
||||||
/// The background color of the navigation bar.
|
/// The background color of the navigation bar.
|
||||||
@Published var navigationBarBackgroundColor: Color = .clear
|
@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.
|
/// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar.
|
||||||
@@ -60,7 +93,10 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
|
|
||||||
/// The content view displayed below the navigation bar.
|
/// The content view displayed below the navigation bar.
|
||||||
let content: Content
|
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.
|
/// Initializes a new `LCENavigationView` instance.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - title: The title view for the navigation bar. Defaults to an empty `Text`.
|
/// - title: The title view for the navigation bar. Defaults to an empty `Text`.
|
||||||
@@ -92,20 +128,31 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
|
|
||||||
/// The private `NavigationBarView` that lays out the navigation bar components.
|
/// The private `NavigationBarView` that lays out the navigation bar components.
|
||||||
private var NavigationBarView: some View {
|
private var NavigationBarView: some View {
|
||||||
HStack {
|
ZStack {
|
||||||
NavLeftButton
|
|
||||||
Spacer()
|
|
||||||
TitleView
|
TitleView
|
||||||
Spacer()
|
.padding(.horizontal, maxButtonWidth)
|
||||||
NavRightButton
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if state.hasLeftButton {
|
||||||
|
NavLeftButton
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if state.hasRightButton {
|
||||||
|
NavRightButton
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding()
|
.padding()
|
||||||
.background {
|
.background {
|
||||||
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top)
|
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
|
.onPreferenceChange(LCENavButtonWidthPreferenceKey.self) { maxButtonWidth = $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The private `TitleView` that displays the title and subtitle.
|
/// The private `TitleView` that displays the title and subtitle.
|
||||||
private var TitleView: some View {
|
private var TitleView: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -115,8 +162,8 @@ 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 {
|
private var NavLeftButton: some View {
|
||||||
Button(action: state.leftButtonAction) {
|
Button(action: state.leftButtonAction) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -126,9 +173,15 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
state.leftButtonText
|
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 {
|
private var NavRightButton: some View {
|
||||||
Button(action: state.rightButtonAction) {
|
Button(action: state.rightButtonAction) {
|
||||||
HStack {
|
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.
|
/// Sets the configuration for the right button of the navigation bar.
|
||||||
@@ -158,14 +217,8 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
state.rightButtonText = text
|
state.rightButtonText = text
|
||||||
state.rightButtonAction = action
|
state.rightButtonAction = action
|
||||||
|
state.hasRightButton = true
|
||||||
if let string = state.leftButtonText.string, string.isEmpty {
|
|
||||||
state.leftButtonText = text.foregroundColor(.clear)
|
|
||||||
}
|
|
||||||
if state.leftButtonImage == nil {
|
|
||||||
state.leftButtonImage = image?.foregroundColor(.clear) as? AnyView
|
|
||||||
}
|
|
||||||
|
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,14 +240,17 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
state.leftButtonText = text
|
state.leftButtonText = text
|
||||||
state.leftButtonAction = action
|
state.leftButtonAction = action
|
||||||
|
state.hasLeftButton = true
|
||||||
if let string = state.rightButtonText.string, string.isEmpty {
|
|
||||||
state.rightButtonText = text.foregroundColor(.clear)
|
return self
|
||||||
}
|
}
|
||||||
if state.rightButtonImage == nil {
|
|
||||||
state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView
|
/// 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
|
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, *)
|
//@available(iOS 15.0, *)
|
||||||
//struct LCENavigationView_Previews: PreviewProvider {
|
//struct LCENavigationView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user