How to scroll automatically when item appears in SwiftUI? (updated) - ios

I have a list of names that appear vertically thanks to an animation, I would like to have auto-scroll when a new name appears but i don't know how to go on...I saw some questions like this but all of them were a 'jump to a number' solution, not progressively scroll...any suggestions?
UPDATED CODE:
struct ContentView: View {
let correctNames = ["Steve", "Bill", "John", "Elon", "Michael", "Justin", "Marcell", "David", "Gabriel", "Eric", "Jeffrey", "Taylor", "Jennifer", "Christian"]
#State private var animating = false
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
ForEach(0..<correctNames.count, id: \.self) { index in
Text("\(correctNames[index])")
.font(.system(size: 60))
.opacity(animating ? 1 : 0)
.animation(.easeIn(duration: 0.5).delay(Double(index) * 0.2), value: animating)
}
}
}
.onAppear {
animating.toggle()
}
}
}

I would do all this in different approach, model-driven and per-item, to have more control on effects.
A possible approach, is to add items into source of truth one by one and add effects depending on this collection growing.
Here is a demo. Tested with Xcode 14 / iOS 16
Main parts:
#State private var items = [Int]() // storage
...
ScrollViewReader { sr in
ScrollView(showsIndicators: false) {
// ...
}
}
.onAppear {
items.append(1) // initial item put
}
...
// iterating model ...
ForEach(items, id: \.self) { index in
// content
Text("\(index)").id(index).font(.system(size: 64))
// ... it is appeared, so we can use transition
.transition(index == items.last ? .opacity : .identity)
.onAppear {
// on last appeared scedule add next ...
DispatchQueue.main.asyncAfter(deadline: .now() + 0.82) {
if index == items.last && index < 50 {
items.append(index + 1)
}
DispatchQueue.main.async {
// ... and animate scrolling to it after add
withAnimation {
sr.scrollTo(index + 1)
}
}
}
}
Test code is here

Related

Toggling from Picker to Image view causes an index out of range error in SwiftUI

I have a view that uses a button to toggle between a Picker and an Image that is a result of the Picker selection. When quickly toggling from the image to the Picker and immediately back, I get a crash with the following error:
Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range
Toggling less quickly doesn't cause this, nor does toggling in the other direction (picker to image and back). Here is the offending code:
import SwiftUI
struct ContentView: View {
#State private var showingPicker = false
#State private var currentNum = 0
#State private var numbers: [Int] = [1, 2, 3, 4, 5]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Number", selection: $currentNum) {
ForEach(0..<numbers.count, id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.wheel)
} else {
Image(systemName: "\(currentNum).circle")
}
Spacer()
Button("Toggle") {
showingPicker = !showingPicker
}
}
}
}
The code works otherwise. I'm new to SwiftUI so I'm still wrapping my head around how views are created/destroyed. I tried changing the order of the properties thinking maybe the array was being accessed before it was recreated(if that's even something that happens) but that had no effect. I also tried ForEach(numbers.indices) instead of ForEach(0..<numbers.count), but it has the same result.
**Edit
I figured out a stop-gap for now. I added #State private var buttonEnabled = true and modified the button:
Button("Toggle") {
showingPicker = !showingPicker
buttonEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
buttonEnabled = true
}
}
.disabled(buttonEnabled == false)
To debounce it. I still want to figure out the problem and make a real fix.
**Edit
Based on comments I've modified the code to take array indexing out of the equation and to better reflect the actual project I'm working on. The code still works, but a quick toggle will cause the exact same crash and error. It also seems to only happen when the .wheel style picker is used, other picker styles don't have this behavior.
enum Icons: String, CaseIterable, Identifiable {
case ear = "Ear"
case cube = "Cube"
case eye = "Eye"
case forward = "Forward"
case gear = "Gear"
func image() -> Image {
switch self {
case .ear:
return Image(systemName: "ear")
case .cube:
return Image(systemName: "cube")
case .eye:
return Image(systemName: "eye")
case .forward:
return Image(systemName: "forward")
case .gear:
return Image(systemName: "gear")
}
}
var id: Self {
return self
}
}
struct ContentView: View {
#State private var showingPicker = false
#State private var currentIcon = Icons.allCases[0]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Icon", selection: $currentIcon) {
ForEach(Icons.allCases) {
$0.image()
}
}
.pickerStyle(.wheel)
} else {
currentIcon.image()
}
Spacer()
Button("Toggle") {
showingPicker.toggle()
}
}
}
}
** Edited once more to remove .self, still no change
ForEach is not a for loop, you can't use array.count and id:\.self you need to use a real id param or use the Identifiable protocol.
However if you just need numbers it also supports this:
ForEach(0..<5) { i in
As long as you don't try to look up an array using i.

turn other toggles off in Swiftui

I'm new to swift and swiftUI, so probably done something obvious wrong, but I display a list of toggles. When one of these is turned on, I want the others to be turned off, so only one of the items can be selected at once. I've got an array of toggles, and that works fine, but I can't get the 'turn off all the other toggles to work' without it giving compile errors. I'm getting errors on the follow lines.
errors
Here's all the code.
import SwiftUI
struct SheetView: View {
#FetchRequest(sortDescriptors:[SortDescriptor(\Book.timestamp)]) private var items: FetchedResults<Book>
#State var doesClose:[Bool] = []
init(numberOfBooks: Int) {
var values: [Bool] = []
for i in 0..<numberOfBooks {
values.append(false)
}
_doesClose = State(initialValue: values)
print(numberOfBooks)
print(self.doesClose)
}
var body: some View {
NavigationStack{
List {
ForEach(Array(items.enumerated()), id: \.element) { idx, item in
HStack (spacing: 20){
VStack (alignment: .leading, spacing: 5) {
Text(item.bookName!)
Toggle("", isOn: $doesClose[idx])
.onChange(of: doesClose[idx], perform: {for i in 0..<doesClose.count {
if(i != idx)
{
doesClose[i] = false
}
}})
}
}
}
}
.navigationTitle("List")
}
}
private func turnAllOtherButtonsOff(onIndex:Int)
{
for i in 0..<doesClose.count {
if(i != onIndex)
{
doesClose[i] = false
}
}
}
}
I also tried running the func at the perform stage with similar errors. Thanks for any help.
Posting this if this helps anyone. I dropped the idea of going through the array, and just concentrated on keeping track of the previous index, and then switching that when there's an onChange. Here's the code.
Toggle("", isOn: $doesClose[idx])
.onChange(of: doesClose[idx], perform: {value in
print(value)
if(value == true)
{
if(oldToggle != -1){
doesClose[oldToggle] = false
}
oldToggle = idx
}
print(oldToggle)
})

How to pass selected struct to another view?

I'm struggling to pass the right data to another View.
The idea is first to select the Task that has Status 0 and pass it to CardContentView to display under the Section of New tasks. If I print the values, its correct but it always displays the first data/array regardless of its Status. What could be done here?
struct Tasks: Identifiable {
let id = UUID()
let name: String
let status: Int
let image: String
}
extension Tasks {
static var testData: [Tasks] {
return [
Tasks(name: "Inprogress", status: 1, image:"a1"),
Tasks(name: "New", status: 0, image:"a2"),
]
}
}
ContentsView
struct ContentsView: View {
#State var items: [Tasks]
var size = 0
var body: some View {
NavigationView {
List {
let new = items.filter({$0.status == 0})
let size = new.count
if size > 0 {
Section(header:Text("\(size)" + " New")){
//let _ = print(new)
ForEach(new.indices, id: \.self) {itemIndex in
NavigationLink(destination: ChosenTask()) {
CardContentView(item: self.$items[itemIndex])
}
}
}
}
}
.navigationBarTitle("My tasks")
}
}
}
CardContentView
struct CardContentView: View {
#Binding var item: Tasks
var body: some View {
HStack {
VStack(alignment: .leading,spacing: 5) {
Label("Name: " + (item.name), systemImage: "person.crop.circle")
.font(.system(size: 12))
.labelStyle(.titleAndIcon)
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(item.image)
.resizable()
.frame(width: 60, height: 70)
}
}
}
You are already passing the item to another view when you call CardContentView. You just have to do the same thing and pass the item to ChosenTask in your NavigationLink. When the user taps the item, SwiftUI will take care of creating and displaying the ChoseTask view for you.
You should also avoid using indices. There is no need. Your struct conforms to Identifiable so you can use it directly
var body: some View {
NavigationView {
List {
let new = items.filter({$0.status == 0})
if !new.isEmpty {
Section(header:Text("\(size)" + " New")){
//let _ = print(new)
ForEach(new) {item in
NavigationLink(destination: ChosenTask(item: item)) {
CardContentView(item: item)
}
}
}
}
}
.navigationBarTitle("My tasks")
}
}

Two extra views put inside of a ForEach with a filtering search bar

So I have a ScrollView that contains a list of all the contacts imported from a user's phone. Above the ScrollView, I have a 'filter search bar' that has a binding that causes the list to show only contacts where the name contains the same string as the search bar filter. For some reason, the last two contacts in the list always pop up at the bottom of the list, no matter what the string is (even if it's a string not contained in any of the contact names on the phone). I tried deleting a contact and the problem persists, because the original contact was just replaced with the new second to last contact. Any help fixing this would be much appreciated!
struct SomeView: View {
#State var friendsFilterText: String = ""
#State var savedContacts: CustomContact = []
var body: some View {
var filteredContactsCount = 0
if friendsFilterText.count != 0 {
for contact in appState.savedContacts {
if contact.name.lowercased().contains(friendsFilterText.lowercased()) {
filteredContactsCount += 1
}
}
} else {
filteredContactsCount = savedContacts.count
}
return HStack {
Image(systemName: "magnifyingglass")
ZStack {
HStack {
Text("Type a name...")
.opacity(friendsFilterText.count > 0 ? 0 : 1)
Spacer()
}
CocoaTextField("", text: $friendsFilterText)
.background(Color.clear)
}
Button(action: {
friendsFilterText = ""
}, label: {
Image(systemName: "multiply.circle.fill")
})
}.frame(height: 38)
HStack(spacing: 10) {
Text("Your contacts (\(filteredContactsCount))")
Spacer()
Button(action: {
fetchContacts()
}, label: {
Image(systemName: "square.and.arrow.down")
})
Button(action: {
// edit button action
}, label: {
Text("Edit")
})
}
ScrollView {
VStack {
ForEach(savedContacts, id: \.self.name) { contact in
if contact.name.lowercased().contains(friendsFilterText.lowercased()) || friendsFilterText.count == 0 {
Button(action: {
// contact button action
}, label: {
HStack(spacing: 20) {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 41))
.frame(width: 41, height: 41)
VStack(alignment: .leading, spacing: 4) {
Text(contact.name)
Text(contact.phoneNumber)
}
Spacer()
}.frame(height: 67)
})
}
}
}
}
}
}
CustomContact is a custom struct with properties phoneNumber and name. I've attached images below of the issue I'm experiencing. I'm thinking MAYBE it's because there's something off timing-wise with the friendsFilterText and the ForEach rendering but I'm really not sure.
In the image set below, the 'Extra Contact 1' and 'Extra Contact 2' are ALWAYS rendered, unless I add a filter, then switch to a different view, then back to this view (which leads me to believe it's a timing thing again).
https://imgur.com/a/CJW2CUS
You should move the count calculation out of the view into a computed var.
And if CustomContact is your single contact struct, it should actually read #State var savedContacts: [CustomContact] = [] i.e. an array of CustomContact.
The rest worked fine with me, no extra contacts showing.
struct ContentView: View {
#State var friendsFilterText: String = ""
#State var savedContacts: [CustomContact] = []
// computed var
var filteredContactsCount: Int {
if friendsFilterText.isEmpty { return savedContacts.count }
return savedContacts.filter({ $0.name.lowercased().contains(friendsFilterText.lowercased()) }).count
}
var body: some View {
...

SwiftUI pagination for List object

I've implemented a List with a search bar in SwiftUI. Now I want to implement paging for this list. When the user scrolls to the bottom of the list, new elements should be loaded. My problem is, how can I detect that the user scrolled to the end? When this happens I want to load new elements, append them and show them to the user.
My code looks like this:
import Foundation
import SwiftUI
struct MyList: View {
#EnvironmentObject var webService: GetRequestsWebService
#ObservedObject var viewModelMyList: MyListViewModel
#State private var query = ""
var body: some View {
let binding = Binding<String>(
get: { self.query },
set: { self.query = $0; self.textFieldChanged($0) }
)
return NavigationView {
// how to detect here when end of the list is reached by scrolling?
List {
// searchbar here inside the list element
TextField("Search...", text: binding) {
self.fetchResults()
}
ForEach(viewModelMyList.items, id: \.id) { item in
MyRow(itemToProcess: item)
}
}
.navigationBarTitle("Title")
}.onAppear(perform: fetchResults)
}
private func textFieldChanged(_ text: String) {
text.isEmpty ? viewModelMyList.fetchResultsThrottelt(for: nil) : viewModelMyList.fetchResultsThrottelt(for: text)
}
private func fetchResults() {
query.isEmpty ? viewModelMyList.fetchResults(for: nil) : viewModelMyList.fetchResults(for: query)
}
}
Also a little bit special this case, because the list contains the search bar. I would be thankful for any advice because with this :).
As you have already a List with an artificial row for the search bar, you can simply add another view to the list which will trigger another fetch when it appears on screen (using onAppear() as suggested by Josh). By doing this you do not have to do any "complicated" calculations to know whether a row is the last row... the artificial row is always the last one!
I already used this in one of my projects and I've never seen this element on the screen, as the loading was triggered so quickly before it appeared on the screen. (You surely can use a transparent/invisible element, or perhaps even use a spinner ;-))
List {
TextField("Search...", text: binding) {
/* ... */
}
ForEach(viewModelMyList.items, id: \.id) { item in
// ...
}
if self.viewModelMyList.hasMoreRows {
Text("Fetching more...")
.onAppear(perform: {
self.viewModelMyList.fetchMore()
})
}
}
Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.
This one worked for me:
You can add pagination with two different approaches to your List: Last item approach and Threshold item approach.
That's way this package adds two functions to RandomAccessCollection:
isLastItem
Use this function to check if the item in the current List item iteration is the last item of your collection.
isThresholdItem
With this function you can find out if the item of the current List item iteration is the item at your defined threshold. Pass an offset (distance to the last item) to the function so the threshold item can be determined.
import SwiftUI
extension RandomAccessCollection where Self.Element: Identifiable {
public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
return distance == 1
}
public func isThresholdItem<Item: Identifiable>(
offset: Int,
item: Item
) -> Bool {
guard !isEmpty else {
return false
}
guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
return false
}
let distance = self.distance(from: itemIndex, to: endIndex)
let offset = offset < count ? offset : count - 1
return offset == (distance - 1)
}
}
Examples
Last item approach:
struct ListPaginationExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isLastItem(item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
Threshold item approach:
struct ListPaginationThresholdExampleView: View {
#State private var items: [String] = Array(0...24).map { "Item \($0)" }
#State private var isLoading: Bool = false
#State private var page: Int = 0
private let pageSize: Int = 25
private let offset: Int = 10
var body: some View {
NavigationView {
List(items) { item in
VStack(alignment: .leading) {
Text(item)
if self.isLoading && self.items.isLastItem(item) {
Divider()
Text("Loading ...")
.padding(.vertical)
}
}.onAppear {
self.listItemAppears(item)
}
}
.navigationBarTitle("List of items")
.navigationBarItems(trailing: Text("Page index: \(page)"))
}
}
}
extension ListPaginationThresholdExampleView {
private func listItemAppears<Item: Identifiable>(_ item: Item) {
if items.isThresholdItem(offset: offset,
item: item) {
isLoading = true
/*
Simulated async behaviour:
Creates items for the next page and
appends them to the list after a short delay
*/
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.page += 1
let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
self.items.append(contentsOf: moreItems)
self.isLoading = false
}
}
}
}
String Extension:
/*
If you want to display an array of strings
in the List view you have to specify a key path,
so each string can be uniquely identified.
With this extension you don't have to do that anymore.
*/
extension String: Identifiable {
public var id: String {
return self
}
}
Christian Elies, code reference

Resources