Reputation: 41
I have implemented a Settings view in my app using Form and Sections. I am using NavigationLinks to transition to subviews.
For one of the subviews I first need to retrieve data from a server. So, if a user is tapping on the item, I'd like to prevent the transition. Instead, I'd like to show a progress indicator, execute a request to the server, and only if the request is successful I'd like to continue with the transition.
This does not seem to be possible out of the box with NavigationLink as it always fires. So I tried to be smart and implement a Binding for isActive like this:
@State var label: LocalizedStringKey
@State var onTap: (@escaping (Bool) -> Void) -> Void
@State private var transition: Bool = false
@State private var inProgress: Bool = false
var body: some View {
ZStack {
HStack {
Text(label)
Spacer()
if inProgress {
ProgressView().progressViewStyle(CircularProgressViewStyle())
} else {
Image(systemName: "chevron.right")
.font(Font.system(.footnote).weight(.semibold))
.foregroundColor(Color(UIColor.systemGray2))
}
}
let isActive = Binding<Bool>(
get: { transition },
set: {
if $0 {
if inProgress {
transition = false
} else {
inProgress = true
onTap(complete)
}
}
}
)
NavigationLink(destination: EmptyView(), isActive: isActive) { EmptyView() }.opacity(0)
}
}
private func complete(success: Bool) {
inProgress = false
transition = success
}
I figured that when a user is tapping the NavigationLink, NavigationLink tries to set isActive to true. So, in my binding I am checking if I have already fired a request to the server using inProgress state. If I haven't fired a request, I do so by calling a generic onTap() method with a completion callback as parameter, but I do not allow isActive to be set to true. Here's how I am using onTap():
KKMenuItem(label: "Profile.Notifications") { complete in
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
complete(false)
}
}
Instead of a real backend request, I am using an asynchronous wait for proof of concept. The parameter of complete() indicates whether the request was successful or not, and only if true the transition to destination in the NavigationLink should happen.
While all of that does work, one thing just does NOT work, namely after tapping on the NavigationLink item, the item remains highlighted. The highlighted color only disappears if either the transition was successful (i.e. isActive became true eventually) or a different NavigationLink in my Form was triggered. You can see it here:
What I want is that the highlight itself only happens when the item is tapped but then it should disappear immediately while the code is waiting for complete() to set the isActive state to true. This is how the Apple Setting app does it for some of its settings. But I just can't figure out how to do that myself in SwiftUI using Form and NavigationLink.
Or is there any other way how I can implement the desired behavior of conditionally transitioning to a NavigationLink, so that I can implement the progress indicator while executing a request to the server in the background?
Upvotes: 1
Views: 283
Reputation: 30341
I did this by controlling the isActive
state.
Example:
struct ContentView: View {
private enum TransitionState {
case inactive
case loading
case active
}
@State private var transitionState: TransitionState = .inactive
var body: some View {
NavigationView {
List {
Button {
guard transitionState == .inactive else { return }
transitionState = .loading
// Mimic 1 second wait
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
transitionState = .active
}
} label: {
HStack {
Text("Waited destination").foregroundColor(.primary)
Spacer()
if transitionState == .loading {
ProgressView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.background(
NavigationLink(
isActive: Binding<Bool>(
get: { transitionState == .active },
set: { isNowActive in
if !isNowActive {
transitionState = .inactive
}
}
),
destination: {
Text("Destination")
},
label: {}
)
.opacity(transitionState == .loading ? 0 : 1)
)
}
}
}
}
Result:
Upvotes: 1
Reputation: 41
Based on the answer I received, I revised my code and also added the highlight effect so that the user gets a visual feedback after tapping. Now this is working very nicely now:
struct KKMenuItem<Destination>: View where Destination: View {
private enum TransitionState {
case inactive
case loading
case active
}
@State var label: LocalizedStringKey
@State var destination: Destination
@State var onTap: (@escaping (Bool) -> Void) -> Void
@State private var transitionState: TransitionState = .inactive
@State private var highlightColor: Color = .clear
var body: some View {
let isActive = Binding<Bool>(
get: { transitionState == .active },
set: { isNowActive in
if !isNowActive {
transitionState = .inactive
}
}
)
Button {
guard transitionState == .inactive else { return }
transitionState = .loading
highlightColor = Color(UIColor.tertiarySystemBackground)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
highlightColor = .clear
}
onTap(complete)
}
label: {
HStack {
Text(label)
Spacer()
if transitionState == .loading {
ProgressView().progressViewStyle(CircularProgressViewStyle())
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
.listRowBackground(highlightColor)
.background(
NavigationLink(destination: destination, isActive: isActive, label: { })
.opacity(transitionState == .loading ? 0 : 1)
)
}
private func complete(success: Bool) {
transitionState = success ? .active : .inactive
}
}
Hopefully, this is helpful to others as well. It took a lot of time for me to get this one right.
Upvotes: 1