ProgressView inside SwiftUI List disappearing after list is updated - list

The circular (default) SwiftUI ProgressView disappears within a List whenever the list gets expanded with new content because of a .onAppear modifier on the ProgressView. 
The ProgressView becomes invisible, although its allocated cell (and its label, if assigned) within the list remain visible.
I want to keep the ProgressView at the bottom of the list so that the list gets automatically updated with new content being downloaded from the server when the user reaches the end of the list.
Possible duplicate of this article but the accepted answer does not tackle the reason why the view disappears.
Reprex:
struct ContentView: View {
#State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6"]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
.frame(height: 100)
}
ProgressView {
Text("Loading")
}
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
.onAppear(perform: endOfListReached)
}
}
func endOfListReached() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
items.append("Item \(items.count + 1)")
}
}
}

You could try this alternative approach using a ScrollViewReader and ScrollView
to keep the ProgressView at the bottom of the list
so that the list gets automatically updated with new content being
downloaded from the server when the user reaches the end of the list.
struct ContentView: View {
#State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6"]
#Namespace var bottomID // id for the ProgressView
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false){
ForEach(items, id: \.self) { item in
Text(item).frame(height: 100)
}
ProgressView { Text("Loading") }.id(bottomID)
}
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
.onAppear(perform: endOfListReached)
.onChange(of: items) { _ in
withAnimation {
proxy.scrollTo(bottomID) // <-- scroll to the ProgressView
}
}
}
}
func endOfListReached() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
items.append("Item \(items.count + 1)")
if items.count < 15 {
endOfListReached() // <--- testing up to 15
}
}
}
}

I would rather keep the List for its convenient layout, but it appears that the List is causing the issue here and that changing it to a ScrollView and LazyVStack and styling it with Dividers works as expected.

Related

How to display default detail in NavigationSplitView after deleting an item — on iPad

I have a NavigationSplitView (SwiftUI 4, iOS16), list of items in the left part, detail on right. When I run the application, no item selected, it displays "Select item" on the right (detail part). I select an item and it displays item detail on right. So far so good.
Now I delete the item on the left by swiping to the left... but the right part still displays the deleted item detail.
Is there any way to get right part go back to the not-selected detail?
Please notice this behaviour can be observed on iPad only, not on iPhone, as iPhone does not display both parts of NavigationSplitView together.
import SwiftUI
struct ContentView: View {
#State var items = ["item 1", "item 2", "item 3", "item 4"]
var body: some View {
NavigationSplitView {
List {
ForEach(items, id: \.self) { item in
NavigationLink(destination: Text(item)) {
Text(item)
}
}
.onDelete(perform: { offsets in
items.remove(atOffsets: offsets)
})
}
} detail: {
Text("Select item")
}
}
}
Bind your selection with List using #State property and also you don't require to add NavigationLink, NavigationSplitView automatically show detail for the selected list item.
Now if you remove the selected item from the list then after deleting it from the array simply set nil to the selection binding of List.
struct ContentView: View {
#State var items = ["item 1", "item 2", "item 3", "item 4"]
#State private var selection: String?
var body: some View {
NavigationSplitView {
List(selection: $selection) {
ForEach(items, id: \.self) { item in
Text(item)
}
.onDelete(perform: { offsets in
items.remove(atOffsets: offsets)
guard selection != nil, !items.contains(selection!) else { return }
selection = nil
})
}
} detail: {
if let selectedItem = selection {
Text(selectedItem)
}
else {
Text("Select item")
}
}
}
}
Suggest you to check the Apple documentation of NavigationSplitView for more details.

SwiftUI: change background color of item in a list when it's tapped on

I have a horizontal scrollview with a list of items, I want to change background color color of items when the user taps on it. This is my code but when I run it and click on items nothing happens.
struct HorizontalList: View {
var list = ["item 1", "item 2", "item 3", "item 4", "item 5", "item 6", "item 7", "item 8", "item 9", "item 10"]
#State var selectedIndex = 0
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(0..<list.count) { index in
ListItem(isSelected: selectedIndex == index, label: list[index])
.listRowBackground(Color.blue)
.onTapGesture {
selectedIndex = index
}
}
}
}
}
}
struct ListItem: View {
#State var isSelected: Bool
#State var label: String
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.foregroundColor(isSelected ? Color.blue : Color.clear)
.frame(minHeight: 16, idealHeight: 16, maxHeight: 16)
Text(label)
}
}
}
You need to use a .simultaneousGesture as the list is using a DragGesture behind the scenes.
.simultaneousGesture(
TapGesture()
.onEnded({ _ in
selectedIndex = index
})
)
Edit:
See the comments in the code.
struct HorizontalList: View {
var listItems = Array(1...10).map( { ListItem(text: "Item \($0)") })
#State var selectedIndex = 0
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
// It is good practice to always supply a ForEach with an identifiable. If you were
// to try to delete an element in the array, you can cause a crash without this.
// What Array(zip()) does is takes listItems and listItems.indices and put them together
// into an array of pairs, requiring two arguments in the ForEach, one for the item and
// one for the index. You can then use both in the loop. However, the ForEach tracks them by
// the identifiable element, not the index which is what the id: \.0 does.
ForEach(Array(zip(listItems, listItems.indices)), id: \.0) { item, index in
ListItemView(isSelected: selectedIndex == index, label: item.text)
.listRowBackground(Color.blue)
.simultaneousGesture(
TapGesture()
.onEnded({ _ in
selectedIndex = index
})
)
}
}
}
}
}
struct ListItemView: View {
// There is no reason for these to be an #State var unless this view changes them. If it does,
// it really should be a Binding to pass the data back.
let isSelected: Bool
let label: String
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.foregroundColor(isSelected ? Color.blue : Color.clear)
.frame(minHeight: 16, idealHeight: 16, maxHeight: 16)
Text(label)
}
}
}
// This becomes the item you itereate on
struct ListItem:Hashable, Identifiable {
let id = UUID()
var text: String
}

Cycle through a Picker selection from Array

I'm quite new to Swift and coding in general so apologies if this is a super simple question.
I'm trying to add a button either side of a Picker to allow the user to move up/down the selections within the Picker (my Picker is populated from an Array) - I'm using this selection in another part of my App.
The code below works but only updates the example Text, but it does not update the selection: within the pickerto update correctly.
Any ideas what I'm doing wrong?
import SwiftUI
struct ContentView: View {
let myArray = ["Period 1", "Period 2", "Period 3", "Period 4", "Period 5", "Period 6", "Period 7", "Period 8", "Period 9", "Period 10", "Period 11", "Period 12", "Period 13"]
#State var currentIndex = 0
var body: some View {
VStack(spacing: 20) {
HStack {
Button(action: {
if currentIndex == 0 {
} else {
currentIndex -= 1
}
}) {
Image(systemName: "chevron.left.circle")
.imageScale(.large)
}
.padding(2)
.frame(maxWidth: .infinity, alignment: .leading)
Picker(selection: $currentIndex, label: Text("Picker")) {
ForEach(myArray, id: \.self) {
Text($0)
}
}
Button(action: {
if currentIndex == 12 {
} else {
currentIndex += 1
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
}
.padding(2)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding()
Text("\(myArray[currentIndex])")
}
}
}
'''
The problem here is that you are programmatically updating your State variable currentIndex but you are not modifying the array selection. To make this work you need to iterate the array over the indices and not the elements so change the Picker code to this
Picker(selection: $currentIndex, label: Text("Picker")) {
ForEach(myArray.indices) { index in
Text(myArray[index])
}
}
Here each item in the picker automatically gets the id of the raw value of index which matches with currentIndex which makes this work.
To work directly with the elements of the array here is an alternative solution where a new State variable is added to hold the selected string.
#State var currentSelection = ""
#State var currentIndex = 0 {
didSet {
currentSelection = myArray[currentIndex]
}
}
The picker code gets changed to
Picker(selection: $currentSelection, label: Text("Picker")) {
ForEach(myArray, id: \.self) { period in
Text(period)
}
}
but the rest of the code is the same since the buttons are still using currentIndex

SwiftUI: How to set the title for the toolbar on macOS 11?

I have a SwiftUI app that has two columns and a toolbar. I'm trying to emulate the latest macOS applications and use the toolbar in the same way that Mail does, i.e. select a sidebar item and show that as the title of the window.
Here is my code:
import SwiftUI
var listItems = ["Item 1", "Item 2", "Item 3", "Item 4"]
var secondItems = ["Second 1", "Second 2", "Second 3", "Second 4"]
struct ContentView: View
{
#State var select: String? = "Item 1"
var body: some View
{
VStack
{
NavigationView
{
List
{
ForEach((0..<listItems.count), id: \.self)
{index in
NavigationLink(destination: SecondView(), tag: listItems[index], selection: $select)
{
Text(listItems[index])
.padding(.vertical, 2.0)
}
}
Spacer()
}.frame(width:160)
}
.toolbar
{
Text("this is not the title")
Button(action: {})
{
Label("Upload", systemImage: "square.and.arrow.up")
}
}
}
}
}
struct SecondView: View
{
var body: some View
{
NavigationView
{
List
{
ForEach((0..<secondItems.count), id: \.self)
{index in
NavigationLink(destination: Text(secondItems[index]))
{
Text(secondItems[index])
.frame(height: 20)
}
}
}.frame(width:150)
}
}
}
So I get a toolbar like this:
but how do I set the title of the window in the toolbar when I select an item in the first list?
Here is how I solved it:
import SwiftUI
var listItems = ["Item 1", "Item 2", "Item 3", "Item 4"]
var secondItems = ["Second 1", "Second 2", "Second 3", "Second 4"]
struct ContentView: View
{
#State var select: String? = "Item 1"
var body: some View
{
VStack
{
NavigationView
{
List
{
ForEach((0..<listItems.count), id: \.self)
{index in
NavigationLink(destination: SecondView(), tag: listItems[index], selection: $select)
{
Text(listItems[index])
.frame(height: 20)
}
}
Spacer()
}
.listStyle(SidebarListStyle())
}
.toolbar
{
Text("this is not the title")
Button(action: {})
{
Label("Upload", systemImage: "square.and.arrow.up")
}
}
.navigationTitle(select!)
.navigationSubtitle("\(listItems.count) records")
.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}
}
struct SecondView: View {
var body: some View {
NavigationView {
List
{
ForEach((0..<secondItems.count), id: \.self)
{index in
NavigationLink(destination: Text(secondItems[index]))
{
Text(secondItems[index])
.frame(height: 20)
}
}
}
}
}
}
HStack(spacing: 0) {
ToolView()
Divider()
PanelView()
}
.frame(width: 290)
.toolbar(id: "id") {
ToolbarItem(id: "sidbar", placement: .primaryAction) {
Button {
} label: {
Label {
Text("Stack")
} icon: {
Image("stack")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.offset(y: 10)
}
}
}
}
Use id to create ToolbarItem and you can show the title with right click on the toolbar.
Use Button to show the content, and the label and image will fit the toolbar.
NOTE: if you have multiple toolbars, make sure to provide each one with an id, or you still won't be able to show the title.
It is best to use SF Symbols, but if you use a custom font, make sure to resize it, and offset it.
If you are attempting to change the text that is currently set to "toolbar", you want to set the navigation title. This is done via .navigationTitle(String) and .navigationSubtitle(String).

SwiftUI Add view below a list

I have a list with some items.
Below the list I'd like to have to button to load more items.
(As loading all items requires some user actions like entering a TAN, this should not be done automatically when the user scrolls to the end of the list, but only if he likes to.)
What I'd like to have is a view like this:
However, if I place the List and the Button in a VStack, the Button get always displayed at the bottom of the screen, not only when I scroll to the end of the List:
struct ContentView: View {
private let items = Range(0...15).map { "Item " + String($0) }
var body: some View {
VStack {
List(items, id: \.self) { item in
Text(item)
}
HStack {
Spacer()
Button("Load more") { print("Load more items") }
Spacer()
}
}
}
}
If I add the Button to the List, the Button obviously gets displayed as a List item with a white background and without any space to the list:
struct ContentView: View {
private let items = Range(0...15).map { "Item " + String($0) }
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
HStack {
Spacer()
Button("Load more") { print("Load more items") }
Spacer()
}
}.listStyle(GroupedListStyle())
}
}
Is there any way to add a view that becomes visible when the user scrolls to the end of the List but that is not part of the List? (Or at least looks like being below the List and not part of it?)
You should use second variant, but a bit tuned, like below (colors/spaces modify per your needs
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
HStack {
Button("Load more") { print("Load more items") }
}
.listRowInsets(EdgeInsets())
.frame(maxWidth: .infinity, minHeight: 60)
.background(Color(UIColor.systemGroupedBackground))
}.listStyle(GroupedListStyle())
}