Skip to content

Commit 1afa49b

Browse files
authored
Merge pull request #1157 from Esri/df/NavigationLayer
Add `NavigationLayer`
2 parents 3012f38 + 82e580d commit 1afa49b

13 files changed

+924
-6
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2025 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import SwiftUI
16+
17+
extension NavigationLayer {
18+
struct Header: View {
19+
/// The model for the navigation layer.
20+
@Environment(NavigationLayerModel.self) private var model
21+
22+
/// The height of the header content.
23+
@State private var height: CGFloat = .zero
24+
25+
/// The optional closure to perform when the back navigation button is pressed.
26+
let backNavigationAction: ((NavigationLayerModel) -> Void)?
27+
28+
/// The header trailing content.
29+
let headerTrailing: (() -> any View)?
30+
31+
/// The width provided to the view.
32+
let width: CGFloat
33+
34+
var body: some View {
35+
HStack {
36+
if backButtonIsVisible {
37+
Button {
38+
if let backNavigationAction {
39+
backNavigationAction(model)
40+
} else {
41+
model.pop()
42+
}
43+
} label: {
44+
let label = Label {
45+
Text("Back")
46+
} icon: {
47+
Image(systemName: "chevron.left")
48+
.font(.title2.weight(.medium))
49+
}
50+
.padding(5)
51+
.contentShape(.rect)
52+
if backLabelIsVisible {
53+
label
54+
.labelStyle(.titleAndIcon)
55+
} else {
56+
label
57+
.labelStyle(.iconOnly)
58+
}
59+
}
60+
#if targetEnvironment(macCatalyst)
61+
.buttonStyle(.plain)
62+
#endif
63+
.frame(!backButtonIsVisible, width: width / 6)
64+
} else if headerTrailing != nil {
65+
// There's no back button, but there's header trailing
66+
// content, so keep the title centered.
67+
Color.clear
68+
.frame(width: width / 6, height: 1)
69+
}
70+
71+
if backButtonIsVisible && !backLabelIsVisible {
72+
Divider()
73+
.frame(height: height)
74+
}
75+
76+
if let title = model.title, !title.isEmpty {
77+
VStack(alignment: backButtonIsVisible ? .leading : .center) {
78+
Text(title)
79+
.bold()
80+
if let subtitle = model.subtitle, !subtitle.isEmpty {
81+
Text(subtitle)
82+
.font(.subheadline)
83+
.foregroundStyle(.secondary)
84+
}
85+
}
86+
.frame(
87+
maxWidth: headerTrailing == nil ? .infinity : (width / 6) * 4,
88+
alignment: backButtonIsVisible ? .leading : .center
89+
)
90+
.lineLimit(1)
91+
.onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in
92+
height = newValue
93+
}
94+
} else if headerTrailing != nil {
95+
// There's no title but there's header trailing content,
96+
// so push it to the right.
97+
Spacer()
98+
}
99+
100+
if let headerTrailing {
101+
AnyView(headerTrailing())
102+
.frame(width: width / 6, alignment: .trailing)
103+
}
104+
}
105+
.padding(headerIsVisible)
106+
.background(model.headerBackgroundColor)
107+
}
108+
109+
/// A Boolean value indicating whether the back button is visible, *true* when there is at least one
110+
/// presented view and *false* otherwise.
111+
var backButtonIsVisible: Bool {
112+
model.presented != nil
113+
}
114+
115+
/// A Boolean value indicating whether the back label is visible, *true* when the back button is
116+
/// visible and there is no title to show, and *false* otherwise.
117+
var backLabelIsVisible: Bool {
118+
backButtonIsVisible && model.title == nil
119+
}
120+
121+
/// A Boolean value indicating whether any header content is visible.
122+
var headerIsVisible: Bool {
123+
backButtonIsVisible || (model.title != nil && !model.title!.isEmpty) || headerTrailing != nil
124+
}
125+
}
126+
}
127+
128+
fileprivate extension View {
129+
/// Optionally positions this view within an invisible frame with the specified size.
130+
/// - Parameters:
131+
/// - applied: A Boolean condition indicating whether padding is applied.
132+
/// - width: A fixed width for the resulting view.
133+
/// - Returns: A view with a fixed width, if applied.
134+
@ViewBuilder
135+
func frame(_ applied: Bool, width: CGFloat) -> some View {
136+
if applied {
137+
self.frame(width: width, alignment: .leading)
138+
} else {
139+
self
140+
}
141+
}
142+
143+
/// Optionally adds an equal padding amount to all edges of this view.
144+
/// - Parameter applied: A Boolean condition indicating whether padding is applied.
145+
/// - Returns: A view that’s padded, if applied.
146+
@ViewBuilder
147+
func padding(_ applied: Bool) -> some View {
148+
if applied {
149+
self.padding()
150+
} else {
151+
self
152+
}
153+
}
154+
}
155+
156+
#Preview("Long title") {
157+
NavigationLayer { _ in
158+
Color.clear
159+
.navigationLayerTitle("Looooooooooooooooooooong title")
160+
}
161+
}
162+
163+
#Preview("Long subtitle") {
164+
NavigationLayer { _ in
165+
Color.clear
166+
.navigationLayerTitle("Title", subtitle: "Looooooooooooooooooooong subtitle")
167+
}
168+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2025 Esri
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import SwiftUI
16+
17+
struct NavigationLayerHeaderBackground: PreferenceKey {
18+
static let defaultValue: Color? = nil
19+
20+
static func reduce(value: inout Color?, nextValue: () -> Color?) {
21+
value = nextValue()
22+
}
23+
}
24+
25+
struct NavigationLayerTitle: PreferenceKey {
26+
static let defaultValue: String? = nil
27+
28+
static func reduce(value: inout String?, nextValue: () -> String?) {
29+
value = nextValue()
30+
}
31+
}
32+
33+
struct NavigationLayerSubtitle: PreferenceKey {
34+
static let defaultValue: String? = nil
35+
36+
static func reduce(value: inout String?, nextValue: () -> String?) {
37+
value = nextValue()
38+
}
39+
}
40+
41+
extension View {
42+
/// Sets a header background color for the navigation layer destination.
43+
/// - Parameter color: The color for the navigation layer destination.
44+
func navigationLayerHeaderBackground(_ color: Color) -> some View {
45+
preference(key: NavigationLayerHeaderBackground.self, value: color)
46+
}
47+
48+
/// Sets a title for the navigation layer destination.
49+
/// - Parameters:
50+
/// - title: The title for the navigation layer destination.
51+
func navigationLayerTitle(_ title: String) -> some View {
52+
preference(key: NavigationLayerTitle.self, value: title)
53+
}
54+
55+
/// Sets a title and subtitle for the navigation layer destination.
56+
/// - Parameters:
57+
/// - title: The title for the navigation layer destination.
58+
/// - subtitle: The subtitle for the navigation layer destination.
59+
func navigationLayerTitle(_ title: String, subtitle: String) -> some View {
60+
preference(key: NavigationLayerTitle.self, value: title)
61+
.preference(key: NavigationLayerSubtitle.self, value: subtitle)
62+
}
63+
}

0 commit comments

Comments
 (0)