swift skips first variable update - swift

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
}
}
}

Related

SwiftUI: Trigger event after scrolling to end of a paged tabview

I'm trying to have a prompt be called after a user reaches the end of a paged TabView and tries to scroll one extra. (For example, if there are three pages, once the user scrolls to the third page and tries to scroll to a fourth, it pulls up the prompt). I tried doing this:
TabView (selection: $currentIndex) {
...
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentIndex) { _ in
}
However, since the user is already on the last tab, the index isn't updated so the prompt can't be called. How can I achieve this?
Doesn't this work for you?. It reacts after showing 1/2 of the last (dummy) view:
struct ContentView: View {
#State private var currentIndex = 0
#State private var showAlert = false
let colors: [Color] = [.blue, .green, .cyan, .teal, .clear]
var body: some View {
TabView (selection: $currentIndex) {
ForEach(0..<5) { i in
ZStack {
colors[i]
Text("Tab \(currentIndex)")
.font(.title)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentIndex) { _ in
if currentIndex == 4 {
currentIndex = 3
showAlert = true
}
}
.alert("You reached the end", isPresented: $showAlert) { }
}
}

NavigationLink remains highlighted after tapping with custom isActive binding

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:

SwiftUI - ForEach Simultaneously Toggle Multiple Buttons

I have a ForEach loop that displays ten unfilled buttons. When the button is clicked, the button assigns the index of the clicked button to a #State variable; only when the value of the state variable is equal to the index of the clicked button is the button filled. What should I change to be able to 1) click on the same button to unfill the button, and 2) fill another button without "unfilling" any already filled buttons?
Here is a minimal, reproducible example:
import SwiftUI
struct Test: View {
#State var selection: Int? = nil
var body: some View {
VStack {
ForEach (0 ..< 10, id: \.self) { number in
Button (action: {
self.selection = number
}, label: {
self.selection == number ? Image(systemName: "heart.fill") : Image(systemName: "heart")
})
}
}
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}
Thanks in advance.
You need to have a selection property that can keep track of multiple buttons, not just one button. I would use a Set here (not an Array, because there's no handy remove-object method there)
struct Test: View {
#State var selection = Set<Int>() /// will contain the indices of all selected buttons
var body: some View {
VStack {
ForEach (0 ..< 10, id: \.self) { number in
Button(action: {
if selection.contains(number) {
selection.remove(number)
} else {
selection.insert(number)
}
}) {
selection.contains(number) ? Image(systemName: "heart.fill") : Image(systemName: "heart")
}
}
}
}
}
You have to maintain state separately for each button.Better option is to use array. Check code below.
import SwiftUI
struct Test: View {
#State var selection: [Int] = Array(repeating:-1,count:10)
var body: some View {
VStack {
ForEach (0..<selection.count, id: \.self) { index in
Button (action: {
if self.selection[index] == index {
self.selection[index] = -1
}else{
self.selection[index] = index
}
}, label: {
self.selection[index] == index ? Image(systemName: "heart.fill") : Image(systemName: "heart")
})
}
}
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}

List scroll freeze on catalyst NavigationView

I've run in to an odd problem with NavigationView on macCatalyst. Here below is a simple app with a sidebar and a detail view. Selecting an item on the sidebar shows a detail view with a scrollable list.
Everything works fine for the first NavigationLink, the detail view displays and is freely scrollable. However, if I select a list item which triggers a link to a second detail view, scrolling starts, then freezes. The app still works, only the detail view scrolling is locked up.
The same code works fine on an iPad without any freeze. If I build for macOS, the NavigationLink in the detail view is non-functional.
Are there any known workarounds ?
This is what it looks like, after clicking on LinkedView, a short scroll then the view freezes. It is still possible to click on the back button or another item on the sidebar, but the list view is blocked.
Here is the code:
ContentView.swift
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
var body: some View {
NavigationView {
List() {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailListView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...4).map({NamedItem(name: "\($0)")})
var body: some View {
VStack {
List {
Text(item.name)
NavigationLink(destination: DetailListView(item: NamedItem(name: "LinkedView"))) {
listItem(" LinkedView", "Item")
.foregroundColor(Color.blue)
}
ForEach(sections) { section in
sectionDetails(section)
}
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
TestListApp.swift
import SwiftUI
#main
struct TestListApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
I had this very same problem with Mac Catalyst app. On real device (iPhone 7 with iOS 14.4.2) there was no problem but with Mac Catalyst (MacBook Pro with Big Sur 11.2.3) the scrolling in the navigation view stuck very randomly as you explained. I figured out that the issue was with Macbook's trackpad and was related to scroll indicators because with external mouse the issue was absent. So the easiest solution to this problem is to hide vertical scroll indicators in navigation view. At least it worked for me. Below is some code from root view 'ContentView' how I did it. It's unfortunate to lose scroll indicators with big data but at least the scrolling works.
import SwiftUI
struct TestView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: NewView()) {
Text("Navigation Link to new view")
}
}
.onAppear {
UITableView.appearance().showsVerticalScrollIndicator = false
}
}
}
}
OK, so I managed to find a workaround, so thought I'd post this for help, until what seems to be a macCatalyst SwiftUI bug is fixed. I have posted a radar for the list freeze problem: FB8994665
The workaround is to use NavigationLink only to the first level of the series of pages which can be navigated (which gives me the sidebar and a toolbar), and from that point onwards use the NavigationStack package to mange links to other pages.
I ran in to a couple of other gotcha's with this arrangement.
Firstly the NavigationView toolbar loses its background when scrolling linked list views (unless the window is defocussed and refocussed), which seems to be another catalyst SwiftUI bug. I solved that by setting the toolbar background colour.
Second gotcha was that under macCatalyst the onTouch view modifier used in NavigationStack's PushView label did not work for most single clicks. It would only trigger consistently for double clicks. I fixed that by using a button to replace the label.
Here is the code, no more list freezes !
import SwiftUI
import NavigationStack
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
init() {
// Ensure toolbar is allways opaque
UINavigationBar.appearance().backgroundColor = UIColor.secondarySystemBackground
}
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
var body: some View {
NavigationStackView {
DetailListView(item: item)
}
}
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
var linked = NamedItem(name: "LinkedView")
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
List {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(item.name)
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: {
self.isSelected = linked.id
})
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
I have continued to experiment with NavigationStack and have made some modifications which will allow it to swap in and out List rows directly. This avoids the problems I was seeing with the NavigationBar background. The navigation bar is setup at the level above the NavigationStackView and changes to the title are passed via a PreferenceKey. The back button on the navigation bar hides if the stack is empty.
The following code makes use of PR#44 of swiftui-navigation-stack
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let depth: Int
let id = UUID()
init(name:String, depth: Int = 0) {
self.name = name
self.depth = depth
}
var linked: NamedItem {
return NamedItem(name: "Linked \(depth+1)", depth:depth+1)
}
}
// Preference Key to send title back down to DetailStackView
struct ListTitleKey: PreferenceKey {
static var defaultValue: String = ""
static func reduce(value: inout String, nextValue: () -> String) {
value = nextValue()
}
}
extension View {
func listTitle(_ title: String) -> some View {
self.preference(key: ListTitleKey.self, value: title)
}
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
#ObservedObject var navigationStack = NavigationStack()
#State var toolbarTitle: String = ""
var body: some View {
List {
NavigationStackView(noGroup: true, navigationStack: navigationStack) {
DetailListView(item: item, linked: item.linked)
.listTitle(item.name)
}
}
.listStyle(PlainListStyle())
.animation(nil)
// Updated title
.onPreferenceChange(ListTitleKey.self) { value in
toolbarTitle = value
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("\(toolbarTitle) \(self.navigationStack.depth)")
.toolbar(content: {
ToolbarItem(id: "BackB", placement: .navigationBarLeading, showsByDefault: self.navigationStack.depth > 0) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
.opacity(self.navigationStack.depth > 0 ? 1.0 : 0.0)
}
})
}
}
struct DetailListView: View {
var item: NamedItem
var linked: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked, linked: linked.linked)
.listTitle(linked.name)
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func buttonAction() {
self.isSelected = linked.id
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: buttonAction)
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}

Unselecting an item on a SwiftUI list crashes the app

I’m having an issue with SwiftUI with optional bindings, basically it’s a List on macOS, where I add a DetailView once an item is selected, if not selected just add a Text.
When I open the app it’s fine, the Text appears, then I add some items and select it, also works fine, DetailView appears, but once I click outside the table, unselecting it, it crashes. Even tough I have a conditional checking for nil, that’s why it works the first time.
I guess the DetailView is keeping a reference to the selectedItem and crashing once it’s set to nil, but I thought the entire body should be refreshed once a State property is changed, which would remove the previous DetailView from memory and not call a new one, right?
Here's the code:
import SwiftUI
struct DetailView: View {
#Binding var text: String
var body: some View {
TextField("123", text: self.$text)
}
}
struct ContentView: View {
#State var text = ""
#State var items = [String]()
#State var selectedItem: String? = nil
var body: some View {
VStack {
HStack {
VStack(alignment: .leading, spacing: 0) {
List(selection: $selectedItem) {
ForEach(items, id: \.self) { item in
Text(item)
}
}
HStack(spacing: 0) {
Button(action: {
self.items.append(UUID().uuidString)
}, label: {
Text("Add")
})
Button(action: {
if let item = self.selectedItem {
self.items.remove(at: self.items.firstIndex(of: item)!)
}
self.selectedItem = nil
}, label: {
Text("Remove")
}).disabled(selectedItem == nil)
}
}
if selectedItem != nil {
DetailView(text: Binding($selectedItem)!)
} else {
Text("Add an item")
}
}
.tabItem {
Text("Test")
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
I guess the DetailView is keeping a reference to the selectedItem and
crashing once it’s set to nil, but I thought the entire body should be
refreshed once a State property is changed, which would remove the
previous DetailView from memory and not call a new one, right?
The order of update is not defined, so I would answer no on above.
Here is a solution. Tested with Xcode 11.4 / iOS 13.4
if selectedItem != nil {
DetailView(text: Binding(get: {self.selectedItem ?? ""},
set: {self.selectedItem = $0}))
} else {
Text("Add an item")
}