NavigationLink remains highlighted after tapping with custom isActive binding - swift

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?

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.

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:

Related

swift skips first variable update

I've been doing the Standford CS193p free course online and I have a strange situation with assignment 6. I'm supposed to create a theme list from which the user can directly start a game with the chosen theme. This list can be edited and when in editMode a tap gesture is used to open a sheet from which the tapped theme is edited. For this the tap gesture takes the index of the tapped theme and stores it as chosenThemeIndex.
I don't understand why this doesn't work the first time after running the code, meaning tapping on any list item opens always index 0 the first time, regardless of tapping on an item with another index. Then when closing the editing sheet and tapping on any other list item the correct theme is opened for editing. This means for me that Swift is skipping the first update from 0 to another index on chosenThemeIndex. Why is this happening and how can I correct this?
The complete app code can be pulled from branch Assignment6 on: https://github.com/kranca/Memorize.git
import SwiftUI
struct ThemeChooser: View {
#EnvironmentObject var store: ThemeStore
#State private var editMode: EditMode = .inactive
#State private var editing = false
var body: some View {
NavigationView {
List {
ForEach(store.themes) { theme in
let game = store.themes[theme].emojis.count > 1 ? EmojiMemoryGame(theme: theme) : nil
NavigationLink(destination: store.themes[theme].emojis.count > 1 ? EmojiMemoryGameView(game: game!) : nil, label: {
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading) {
Text(theme.name)
Text("Pairs: \(theme.cardPairs)")
}
RoundedRectangle(cornerRadius: 5)
.size(width: 30, height: 45)
.fill()
.foregroundColor(Color(rgbaColor: theme.rgbaColor))
}
Text(theme.emojis)
}
// call on gesture active only when in editMode
.gesture(editMode == .active ? tap(on: store.themes.firstIndex(of: theme) ?? 0) : nil)
})
}
.onDelete(perform: { indexSet in
store.themes.remove(atOffsets: indexSet)
})
.onMove(perform: { indexSet, newOffset in
store.themes.move(fromOffsets: indexSet, toOffset: newOffset)
})
}
.navigationTitle("Choose a theme!")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { editMode == .active ? newThemeButton : nil }
ToolbarItem { EditButton() }
}
.sheet(isPresented: $editing) {
ThemeEditor(theme: $store.themes[chosenThemeIndex])
}
.environment(\.editMode, $editMode)
}
}
// variable I want to update
#State private var chosenThemeIndex: Int = 0
// gesture which takes tapped index and updates chosenThemeIndex
func tap(on tapedThemeIndex: Int) -> some Gesture {
TapGesture().onEnded {
chosenThemeIndex = tapedThemeIndex
editing = true
}
}
private var newThemeButton: some View {
Button("Add New Theme") {
chosenThemeIndex = 0
store.insertTheme(named: "", cardPairs: 2)
editing = true
}
}
}

Unable to rerender a View using onAppear

I am trying to show a pause button if a sound file is playing, i have a uniform source of truth for the sound file, which i can access via ViewModel, now all works well on other Views, but on parent View where all navigation links are, when i go back to it using the back button from other Views, the miniplayer that shows pause disappears...
So i decided that on the .onAppear of NavigationView or text view of parent View i will implement the logic that can detect if a sound file is playing and if so , show a button at bottom to pause the sound file.
Now i can use print and it shows correct value on onAppear in terms of sound file playing or not, but the moment i try to use HStack or any other View to be added i get warning -
Result of 'HStack<Content>' initializer is unused
Now if i decide to use State then also i get similar warning, how can i make the View rerender onAppear, or is that not possible, if that is the case from where i can implement this logic, thanks ....
import Foundation
import SwiftUI
struct HomePageTabView: View {
#Binding var songLVM: SongListVM
#State var miniBar: Bool = false
init(songLVM: Binding<SongListVM>){
self._songLVM = songLVM
UITableView.appearance().backgroundColor = UIColor(.white)
}
var body: some View {
NavigationView {
List {
//Artists
NavigationLink(
destination: ArtistList(songLVM: $songLVM))
{
HStack {
Image(systemName: "music.mic")
Text("Artists")
}
}
//Albums
NavigationLink(
destination: Text("Albums"))
{
HStack {
Image(systemName: "music.note.list")
Text("Albums")
}
}
//Collections
NavigationLink(
//destination: ArtistView())
destination: ArtistViewMain( songLVM: $songLVM))
{
HStack {
Image(systemName: "music.quarternote.3")
Text("Collections")
}
}
//About Us
NavigationLink(
destination: Text("About Us"))
{
HStack {
Image(systemName: "music.note.house.fill")
Text("About Us")
}
}
//Contact Us
NavigationLink(
destination: ArtistView())
{
HStack {
Image(systemName: "phone.circle")
Text("Contact Us")
}
}
}
}
.onAppear {
if(songLVM.audioPlayer?.isPlaying != nil){
HStack {
Button("Stop") {
songLVM.audioPlayer?.stop()
}
}
}
}
}
}
I had also tried
.onAppear{
miniBar.toggle()
if(miniBar == true){
HStack {
Text("Stop")
}
}
}
but got Result of 'HStack<Content>' initializer is unused
I will give easy and basic template for working with swift's ui states.
You can refer it and add your views or navigation link.
struct YourView: View {
/// If you want to pass it on init, use #ObservedObject instead
/// https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject
#StateObject var viewModel = YourViewModel()
var body: some View {
NavigationView {
VStack {
if viewModel.isPlaying {
Button {
viewModel.stop()
} label: {
Text("Stop")
}
} else {
Button {
viewModel.start()
} label: {
Text("Start")
}
}
Toggle(isOn: $viewModel.isPlaying) {
Text("isPlaying")
}
}
}
.onAppear {
viewModel.transform()
}
}
}
class YourViewModel: ObservableObject {
#Published var isPlaying = false
func transform() {
fetchStatus()
}
func fetchStatus() {
isPlaying = true
}
func stop() { isPlaying = false }
func start() { isPlaying = true }
}

SwiftUI: Change a view's transition dynamically after the view is created

I want to change a view's transition dynamically after the view is created. I toggle a State variable isTransition1 by clicking a button to switch between transition1 and transition2 as the below. However, it doesn't work as intended if one of these transitions is opacity. The view to be removed immediately after changing transition always keeps its original transition. Surprisingly, if I change transition2 to slide, it will work without problem. The view to be removed will use the new transition. Is there any way to make opacity work here?
let transition1 = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
let transition2 = AnyTransition.opacity
struct Wrapper1<Content: View>: View {
let content: Content
var body: some View {
content
}
}
struct Wrapper2<Content: View>: View {
let content: Content
var body: some View {
content
}
}
struct TextView: View {
let count: Int
let color: Color
var body: some View {
ZStack {
color
.edgesIgnoringSafeArea(.all)
.frame(maxWidth: UIScreen.main.bounds.width,
maxHeight: UIScreen.main.bounds.height)
Text("Count: \(count)")
.font(.title)
.offset(y: -200)
}
}
}
struct ContentView: View {
#State private var count = 0
#State private var isTransition1 = false
var body: some View {
ZStack {
if count % 2 == 0 {
Wrapper1(content: TextView(count: count, color: Color.green)
.transition(isTransition1 ? transition1 : transition2))
} else {
Wrapper2(content: TextView(count: count, color: Color.red)
.transition(isTransition1 ? transition1 : transition2))
}
HStack(spacing: 100) {
Button(action: {
self.isTransition1.toggle()
}) {
Text("Toggle Transition").font(.title)
}
Button(action: {
withAnimation(.linear(duration: 2)) {
self.count += 1
}
}) {
Text("Increase").font(.title)
}
}
}
}
}
Not sure if I correctly understood what effect you tried to achieve, but try to reset view hierarchy (at least this definitely resets transitions, so they don't affect each other):
var body: some View {
ZStack {
if count % 2 == 0 {
Wrapper1(content: TextView(count: count, color: Color.green)
.transition(isTransition1 ? transition1 : transition2))
} else {
Wrapper2(content: TextView(count: count, color: Color.red)
.transition(isTransition1 ? transition1 : transition2))
}
HStack(spacing: 100) {
Button(action: {
self.isTransition1.toggle()
}) {
Text("Toggle Transition").font(.title)
}
Button(action: {
withAnimation(.linear(duration: 2)) {
self.count += 1
}
}) {
Text("Increase").font(.title)
}
}
}.id(isTransition1) // << here !!
}
I had the same issue recently where I wanted to change the transition inbetween states. Nothing seemed to work until I decided to create an intermediate internal state that updates the UI only after it has updated the transition. I am using a view model that is an observable object.
To solve the issue I created an internal state that is a currentValueSubject.
I also made my transition a published Variable so as to update the UI after the transition changes.
I update the internal state, which in turn updates the transition, which then updates the UI before changing the state.
private var internalState: CurrentValueSubject<BookingWizardState, Never> = CurrentValueSubject(.date)
#Published var state: BookingWizardState
private let moveForwardTransition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let moveBackwardTransition = AnyTransition.asymmetric(insertion: .move(edge: .leading),
removal: .move(edge: .trailing))
#Published var transition: AnyTransition
func setupSubscriptions() {
//Set the transition based on the state change
internalState.map { [weak self] newState in
guard let self else { return .slide }
let isForward = self.state.rawValue <= newState.rawValue
return isForward ? self.moveForwardTransition : self.moveBackwardTransition
}
.assign(to: &$transition)
//Update the external state after a fraction of a second and after the transition has been updated.
internalState
.delay(for: .seconds(0.1), scheduler: RunLoop.main)
.assign(to: &$state)
}

Testing a Dynamic Button View

I've inherited a SwiftUI codebase and am attempting to write some UITests for it.
Now, I'm still catching up to speed with writing Swift and SwiftUI code, so I'm not sure of the legitimacy of the design pattern being utilized. There's essentially an initial view, that starts with an initial #State and begins to pass it down from there into later view through the #Binding tag.
Due to Swift's UI testing requiring an Accessibility label, the button below is causing me a bit of a headache. For a handful of views, there is only ever one BottomTextButton with the content within it dynamically changing based on the case statement and the view we are viewing.
struct TextButton {
enum ButtonType {
case signUp
case logIn
}
}
//View for the bottom text button in the signup / login flow.
struct BottomTextButton: View {
#Binding var loginSignupScreen: LoginSignupFlow.ScreenType
#Binding var bottomTextButton: TextButton.ButtonType
#Binding var signUpCTA: String
#Binding var transitonBackward: Bool
#Binding var buttonAccessibilityLabel: String
var body: some View {
Button(action: {
switch self.bottomTextButton {
case .logIn:
self.transitonBackward = false
self.loginSignupScreen = .logIn
self.bottomTextButton = .signUp
self.signUpCTA = "Text me a code"
self.buttonAccessibilityLabel = "logIn"
case .signUp:
self.transitonBackward = true
self.loginSignupScreen = .signUp
self.bottomTextButton = .logIn
self.buttonAccessibilityLabel = "signUp"
}
}) {
VStack(spacing: 0) {
bottomTextButtonFunction()
}
.padding([.bottom], 20)
.frame(width: UIScreen.main.bounds.width)
}
.accessibility(label: Text("\(self.buttonAccessibilityLabel)"))
}
func bottomTextButtonFunction() -> AnyView {
switch bottomTextButton {
case .signUp: return AnyView(SignUpButtonView())
case .logIn: return AnyView(LogInButtonView())
}
}
}
//Sign Up Button
struct SignUpButtonView: View {
var body: some View {
VStack(spacing: 0) {
Text("Don't have an account?")
.scaledFont(name: "Gotham Light", size: 16)
.foregroundColor(Color("baydynamic"))
Text("Sign Up")
.scaledFont(name: "Gotham Medium", size: 16)
.foregroundColor(Color("baydynamic"))
}
}
}
//Log In Button
struct LogInButtonView: View {
var body: some View {
VStack(spacing: 0) {
Text("Already have an account?")
.scaledFont(name: "Gotham Light", size: 16)
.foregroundColor(Color("baydynamic"))
Text("Log In")
.scaledFont(name: "Gotham Medium", size: 16)
.foregroundColor(Color("baydynamic"))
}
}
}
}
}
Here is an example of how the BottomTextButton is being implemented:
HStack(alignment: .top) {
VStack {
if self.loginSignupScreen == .logIn {
LoginView(
loginSignupScreen: $loginSignupScreen,
bottomTextButton: $bottomTextButton,
loggedIn: $loggedIn,
transitonBackward: $transitonBackward
)} else if self.loginSignupScreen == .signUp {
SignUpView(
loginSignupScreen: $loginSignupScreen,
bottomTextButton: $bottomTextButton,
signUpCTA: $signUpCTA,
transitonBackward: $transitonBackward
)}
}
}
VStack {
BottomTextButton(
loginSignupScreen: $loginSignupScreen,
bottomTextButton: $bottomTextButton,
signUpCTA: $signUpCTA,
transitonBackward: $transitonBackward,
buttonAccessibilityLabel: $buttonAccessibilityLabel
)
}
Now, I am trying to dynamically alter the accessibility label but then I run into the error:
Failed to synthesize event: Failed to scroll to visible (by AX action) Button, label: 'signUp', error: Error kAXErrorCannotComplete performing AXAction 2003 on element AX element pid: 4327, elementOrHash.elementID: 105553148256224.19
Here is an example of how I am attempting to test this:
func testSignUpViewForm() {
let loginBottomButton = app.buttons["logIn"]
XCTAssertTrue(loginBottomButton.exists)
loginBottomButton.tap()
let signUpBottomButton = app.buttons["signUp"]
XCTAssertTrue(signUpBottomButton.exists)
signUpBottomButton.tap()
}

Animate State change in SwiftUI

I‘m currently playing around with SwiftUI. In SwiftUI it‘s possible, to animate a State change for example like so:
struct Foo: View {
#State private var show = false
var body: some View {
VStack {
if show {
Text("Foo")
}
Button(action: {
withAnimation {
self.show.toggle()
}
}) {
Text(show ? "Hide" : "Show")
}
}
}
}
But if I have for example a TextField:
struct Foo: View {
#State private var text = ""
var body: some View {
VStack {
TextField($text, placeholder: Text("Foo")) {
print("editing ended")
}
if !text.isEmpty {
Button(action: {}) {
Text("Done")
}
}
}
}
}
I‘m not able to find a way to animate this change, because the State property is changed by the TextField without a call to withAnimation().
Is it possible to get this change animated?
Just add the animation modifier to wrap your button
var body: some View {
VStack {
TextField($text, placeholder: Text("Foo")) {
print("editing ended")
}
// if !text.isEmpty {
Button(action: {}) {
Text("Done")
}
.background(text.isEmpty ? Color.red : Color.yellow )
//.animation(.basic(duration: 1))
.animation(Animation.default.speed(1))
}
}
}
TextField("Placeholder", text:$text.animation())
Everything that uses the text will be animated when it is changed.