Insert, update and delete animations with ForEach in SwiftUI - swift

I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).
As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.
A working SwiftUI example snippet is attached (Build with Xcode 11.4)
So, the question:
Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?
Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)
Cheers,
Orlando 🍻
Edit
bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.
struct ContentView: View {
#State var items: [Item] = [
Item(name: "Tim"),
Item(name: "Steve"),
Item(name: "Bill")
]
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
private var AddButton: some View {
Button(action: {
self.items.insert(Item(name: "Jeff"), at: 0)
}) {
Text("Add")
}
}
private var RenameButton: some View {
Button(action: {
self.items[0].name = "Craigh"
}) {
Text("Rename first")
}
}
}
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.animation(.spring())
.transition(.move(edge: .leading))
}
}
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.
So the add button will look like this:
private var AddButton: some View {
Button(action: {
withAnimation(.spring()) {
self.items.insert(Item(name: "Jeff"), at: 0)
}
}) {
Text("Add")
}
}
and your Row will look like this:
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.transition(.move(edge: .leading))
}
}

The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value.
This way, you can also animate values that you receive from your view model.
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
.animation(.spring(), value: items) // <<< here
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}

Related

SwiftUI NavigationLink style within ScrollView

I have a screen where I'm trying to display a list of NavigationLink and a grid of items (using LazyVGrid). I first tried putting everything in a List, like this:
List() {
ForEach(items) { item in
NavigationLink(destination: MyDestination()) {
Text("Navigation link text")
}
}
LazyVGrid(columns: columns) {
ForEach(gridItems) { gridItem in
MyGridItem()
}
}
}
However, it seems that putting a LazyVGrid in a List doesn't load the items in the grid lazily, it loads them all at once. So I replaced the List with a ScrollView and it works properly. However, I do want to keep the style of the NavigationLink that is shown when they are in a List. Basically what this looks like https://www.simpleswiftguide.com/wp-content/uploads/2019/10/Screen-Shot-2019-10-05-at-3.00.34-PM.png instead of https://miro.medium.com/max/800/1*LT7ZwIaidXrMuR6pu1Jvgg.png.
How can this be achieved? Or is there a way to put a LazyVGrid in a List and still have it load lazily?
Take a loot at this, the List is already a built-in Lazy load scroll view, you can check that is lazy on the onAppear event
import SwiftUI
struct item:Identifiable {
let id = UUID()
let value: String
}
struct listTest: View {
#State var items: [item]
init () {
var newItems = [item]()
for i in 1...50 {
newItems.append(item(value: "ITEM # \(i)"))
}
self.items = newItems
}
var body: some View {
NavigationView {
List($items) { $item in
NavigationLink(destination: MyDestination(value: item.value)) {
Text("Navigation link text")
.onAppear{
print("IM ITEM: \(item.value)")
}
.id(UUID())
}
}
}
}
}
struct MyDestination: View {
let value: String
var body: some View {
ZStack{
Text("HI A DETAIL: \(value)")
}
}
}

TextField in a list not working well in SwiftUI

This problem is with SwiftUI for a iPhone 12 app, Using xcode 13.1.
I build a List with TextField in each row, but every time i try to edit the contents, it is only allow me tap one time and enter only one character then can not keep enter characters anymore, unless i tap again then enter another one character.Did i write something code wrong with it?
class PieChartViewModel: ObservableObject, Identifiable {
#Published var options = ["How are you", "你好", "Let's go to zoo", "OKKKKK", "什麼情況??", "yesssss", "二百五", "明天見"]
}
struct OptionsView: View {
#ObservedObject var viewModel: PieChartViewModel
var body: some View {
NavigationView {
List {
ForEach($viewModel.options, id: \.self) { $option in
TextField(option, text: $option)
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
func addNewOption() {
viewModel.options.insert("", at: viewModel.options.count)
}
}
struct OptionsView_Previews: PreviewProvider {
static var previews: some View {
let pieChart = PieChartViewModel()
OptionsView(viewModel: pieChart)
}
}
Welcome to StackOverflow! Your issue is that you are directly updating an ObservableObject in the TextField. Every change you make to the model, causes a redraw of your view, which, of course, kicks your focus from the TextField. The easiest answer is to implement your own Binding on the TextField. That will cause the model to update, without constantly redrawing your view:
struct OptionsView: View {
// You should be using #StateObject instead of #ObservedObject, but either should work.
#StateObject var model = PieChartViewModel()
#State var newText = ""
var body: some View {
NavigationView {
VStack {
List {
ForEach(model.options, id: \.self) { option in
Text(option)
}
}
List {
//Using Array(zip()) allows you to sort by the element, but use the index.
//This matters if you are rearranging or deleting the elements in a list.
ForEach(Array(zip(model.options, model.options.indices)), id: \.0) { option, index in
// Binding implemented here.
TextField(option, text: Binding<String>(
get: {
model.options[index]
},
set: { newValue in
//You can't update the model here because you will get the same behavior
//that you were getting in the first place.
newText = newValue
}))
.onSubmit {
//The model is updated here.
model.options[index] = newText
newText = ""
}
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
}
func addNewOption() {
model.options.insert("", at: model.options.count)
}
}

NavigationLink with isActive creates unusual scrolling behavior in macOS SwiftUI app

I have a macOS app with a 3-column view. In the first column, there is a List of items that is lengthy -- perhaps a couple hundred items.
If my NavigationLink for each item contains a isActive parameter, when clicking on the NavigationLinks, I get unpredictable/unwanted scrolling behavior on the list.
For example, if I scroll down to Item 100 and click on it, the List may decide to scroll back up to Item 35 or so (where the active NavigationLink is out of the frame). The behavior seems somewhat non-deterministic -- it doesn't always scroll to the same place. It seems less likely to scroll to an odd location if I scroll through the list to my desired item and then wait for the system scroll bars to disappear before clicking on the NavigationLink, but it doesn't make the problem disappear completely.
If I remove the isActive parameter, the scroll position is maintained when clicking on the NavigationLinks.
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue { activeItem = item }
}
}
var body: some View {
List(items) { item in
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item) //<-- Remove this line and everything works as expected
) {
Text(item.name)
}
}.listStyle(SidebarListStyle())
}
}
struct InnerSidebar : View {
#State private var items = Array(0...100).map { Item(name: "Inner item \($0)") }
var body: some View {
List(items) { item in
NavigationLink(destination: Text("Detail")) {
Text(item.name)
}
}
}
}
I would like to keep isActive, as I have some programatic navigation that I'd like to be able to do that depends on it. For example:
Button("Go to item 10") {
activeItem = items[10]
}
Is there any way to use isActive on the NavigationLink and avoid the unpredictable scrolling behavior?
(Built and tested with macOS 11.3 and Xcode 13.0)
The observed effect is because body of your main view is refreshed and all internals rebuilt. To avoid this we can separate sensitive part of view hierarchy into standalone view, so SwiftUI engine see that dependency not changed and view should not be updated.
Here is a fixed parts:
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
var body: some View {
List(items) {
SidebarRowView(item: $0, activeItem: $activeItem) // << here !!
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
Tested with Xcode 13 / macOS 11.6

Is it possible to perform an action on NavigationLink tap?

I have a simple View showing a list of 3 items. When the user taps on an item, it navigates to the next view. This works fine. However, I would like to also perform an action (set a variable in a View Model) when a list item is tapped.
Is this possible? Here's the code:
import SwiftUI
struct SportSelectionView: View {
#EnvironmentObject var workoutSession: WorkoutManager
let sports = ["Swim", "Bike", "Run"]
var body: some View {
List(sports, id: \.self) { sport in
NavigationLink(destination: ContentView().environmentObject(workoutSession)) {
Text(sport)
}
}.onAppear() {
// Request HealthKit store authorization.
self.workoutSession.requestAuthorization()
}
}
}
struct DisciplineSelectionView_Previews: PreviewProvider {
static var previews: some View {
SportSelectionView().environmentObject(WorkoutManager())
}
}
The easiest way I've found to get around this issue is to add an .onAppear call to the destination view of the NavigationLink. Technically, the action will happen when the ContentView() appears and not when the NavigationLink is clicked.. but the difference will be milliseconds and probably irrelevant.
NavigationLink(destination:
ContentView()
.environmentObject(workoutSession)
.onAppear {
// add action here
}
)
Here's a solution that is a little different than the onAppear approach. By creating your own Binding for isActive in the NavigationLink, you can introduce a side effect when it's set. I've implemented it here all within the view, but I would probably do this in an ObservableObject if I were really putting it into practice:
struct ContentView: View {
#State var _navLinkActive = false
var navLinkBinding : Binding<Bool> {
Binding<Bool> { () -> Bool in
return _navLinkActive
} set: { (newValue) in
if newValue {
print("Side effect")
}
_navLinkActive = newValue
}
}
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Dest"),
isActive: navLinkBinding,
label: {
Text("Navigate")
})
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

Fatal error: Index out of range in SwiftUI

I made a practice app where the main view is a simple list. When the item of the list is tapped, it presents the detail view. Inside the detail view is a “textField” to change the items title.
I always have the error by making this steps:
add 3 items to the list
change the title of the second item
delete the third item
delete the second item (the one you changed the title.
When you delete the item that you changed the name, the app will crash and presente me the following error: “Fatal error: Index out of range in SwiftUI”
How can I fix it?
The main view:
struct ContentView: View {
#EnvironmentObject var store: CPStore
var body: some View {
NavigationView {
VStack {
List {
ForEach(0..<store.items.count, id:\.self) { index in
NavigationLink(destination: Detail(index: index)) {
VStack {
Text(self.store.items[index].title)
}
}
}
.onDelete(perform: remove)
}
Spacer()
Button(action: {
self.add()
}) {
ZStack {
Circle()
.frame(width: 87, height: 87)
}
}
}
.navigationBarTitle("Practice")
.navigationBarItems(trailing: EditButton())
}
}
func remove(at offsets: IndexSet) {
withAnimation {
store.items.remove(atOffsets: offsets)
}
}
func add() {
withAnimation {
store.items.append(CPModel(title: "Item \(store.items.count + 1)"))
}
}
}
The detail view:
struct Detail: View {
#EnvironmentObject var store: CPStore
let index: Int
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $store.items[index].title)
}
}
}
The model:
struct CPModel: Identifiable {
var id = UUID()
var title: String
}
And view model:
class CPStore: ObservableObject {
#Published var items = [CPModel]()
}
Instead of getting the number of items in an array and using that index to get items from your array of objects, you can get each item in the foreach instead. In your content view, change your For each to this
ForEach(store.items, id:\.self) { item in
NavigationLink(destination: Detail(item: item)) {
VStack {
Text(item.title)
}
}
}
And Change your detail view to this:
struct Detail: View {
#EnvironmentObject var store: CPStore
#State var item: CPModel
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $item.title)
}
}
}
My guess is that when you delete item index 1, Detail for index 1 is triggered to re-render before ContentView is triggered. Since SwiftUI doesn't know index has to be updated first because index is a value type (independent of anything).
As another answer has already pointed out, give it a self-contained copy should solve the problem.
In general you should avoid saving a copy of the index since you now have to maintain consistency at all times between two sources of truth.
In terms of your usage, index is implying "index that is currently legit", which should come from your observed object.