An interesting animation with SwiftUI's LazyVGrid on multiple items insert and remove - animation

I have a button where let the user see some more of the items on tap. Initially it's shows of 4 items. After tap, I add rest of the items to the list and for less, I just show the first 4 items. The default animation gets weird every time playing with it. It's overlapping, comes from bottom. For demonstration purpose, I have slow down the animations in simulator.
You can find a demonstration app's source code: https://github.com/nesimtunc/swiftui-playground
Basically this is the whole code.
What's wrong with my implementation? Why this default animation is like this? How can I implement one without side effects?
PS: I have already tried MatchedGeometry and it didn't helped.
Thank you!
import Foundation
import SwiftUI
class ItemModel<T>: NSObject, ObservableObject {
var items: [T]
let showMoreText: String
let showLessText: String
let visibleItemsCount: Int
#Published var visibleItems: [T] = []
#Published var showAll: Bool = false
#Published var toggleText: String = ""
init(
items: [T],
showMoreText: String,
showLessText: String,
visibleItemsCount: Int,
showAll: Bool = false
) {
self.items = items
self.showMoreText = showMoreText
self.showLessText = showLessText
self.visibleItemsCount = visibleItemsCount
self.showAll = showAll
visibleItems = showAll ? items : Array(items.prefix(visibleItemsCount))
toggleText = showAll ? showLessText : showMoreText
}
func toggle() {
showAll.toggle()
update()
}
private func update() {
visibleItems = showAll ? items : Array(items.prefix(visibleItemsCount))
toggleText = showAll ? showLessText : showMoreText
}
}
struct ContentView: View {
private var col = Array(repeating: GridItem(.flexible(), spacing: 16), count: 2)
private let visibleItemsCount = 4
private let spacing: CGFloat = 16.0
#ObservedObject private var model: ItemModel<Int>
init() {
var newItems = [Int]()
for i in 0..<10 {
newItems.append(i)
}
self.model = ItemModel(items: newItems,
showMoreText: "Show More",
showLessText: "Show Less",
visibleItemsCount: visibleItemsCount)
}
var body: some View {
ScrollView {
LazyVGrid(columns: col , alignment: .center, spacing: spacing) {
ForEach(model.visibleItems, id: \.self) { i in
Text("\(i)")
.frame(maxWidth: .infinity, minHeight: 100)
.font(.title)
.foregroundColor(Color.white)
.background(Rectangle().fill(Color.orange))
}
}
Button {
withAnimation {
model.toggle()
}
} label: {
Text(model.toggleText)
.fontWeight(.semibold)
.foregroundColor(Color.primary)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Capsule().strokeBorder(Color.secondary, lineWidth: 1.5))
}
// This is on for demonstartion purpose, using same data but with whole
LazyVGrid(columns: col , alignment: .center, spacing: spacing) {
ForEach(model.items, id: \.self) { i in
Text("\(i)")
.frame(width: 100, height: 100, alignment: .center)
.font(.title)
.foregroundColor(Color.white)
.background(Rectangle().fill(Color.blue))
}
}
}.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.environment(\.sizeCategory, .small)
.previewDevice("iPhone 13 Pro Max")
.previewLayout(.device)
}
}
}

LazyVGrid is lazy, its whole content size is not always ready to animate when putting inside a ScrollView. There's a conflict I guess. I would prefer to keep only one LazyVGrid.
var body: some View {
ScrollView {
LazyVGrid(columns: col , alignment: .center, spacing: spacing) {
Section(footer: showHideButton) {
ForEach(model.visibleItems, id: \.self) { i in
Text("\(i)")
.frame(maxWidth: .infinity, minHeight: 100)
.font(.title)
.foregroundColor(Color.white)
.background(Rectangle().fill(Color.orange))
}.transaction { $0.animation = nil } // --> this line can be removed
}
Section {
ForEach(20...30, id: \.self) { i in
Text("\(i)")
.frame(maxWidth: .infinity, minHeight: 100)
.font(.title)
.foregroundColor(Color.white)
.background(Rectangle().fill(Color.blue))
}
}
}
}.padding()
}
#ViewBuilder
var showHideButton: some View {
Button {
withAnimation {
model.toggle()
}
} label: {
Text(model.toggleText)
.fontWeight(.semibold)
.foregroundColor(Color.primary)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Capsule().strokeBorder(Color.secondary, lineWidth: 1.5))
}
}
There's another thing called Transitions, please also take a look if you need more advance animations
https://www.objc.io/blog/2022/04/14/transitions/

Related

SwiftUI - How to animate components corresponding to array elements?

I have an HStack of circles in SwiftUI, and the number of circles is determined based on the length of an array, like this:
#State var myArr = [...]
...
ScrollView(.horizontal) {
HStack {
ForEach(myArr) { item in
Circle()
//.frame(...)
//.animation(...) I tried this, it didn't work
}
}
}
Then I have a button that appends an element to this array, effectively adding a circle to the view:
Button {
myArr.append(...)
} label: {
...
}
The button works as intended, however, the new circle that is added to the view appears very abruptly, and seems choppy. How can I animate this in any way? Perhaps it slides in from the side, or grows from a very small circle to its normal size.
You are missing transition, here is what you looking:
struct ContentView: View {
#State private var array: [Int] = Array(0...2)
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(array, id:\.self) { item in
Circle()
.frame(width: 50, height: 50)
.transition(AnyTransition.scale)
}
}
}
.animation(.default, value: array.count)
Button("add new circle") {
array.append(array.count)
}
Button("remove a circle") {
if array.count > 0 {
array.remove(at: array.count - 1)
}
}
}
}
a version with automatic scroll to the last circle:
struct myItem: Identifiable, Equatable {
let id = UUID()
var size: CGFloat
}
struct ContentView: View {
#State private var myArr: [myItem] = [
myItem(size: 10),
myItem(size: 40),
myItem(size: 30)
]
var body: some View {
ScrollViewReader { scrollProxy in
VStack(alignment: .leading) {
Spacer()
ScrollView(.horizontal) {
HStack {
ForEach(myArr) { item in
Circle()
.id(item.id)
.frame(width: item.size, height: item.size)
.transition(.scale)
}
}
}
.animation(.easeInOut(duration: 1), value: myArr)
Spacer()
Button("Add One") {
let new = myItem(size: CGFloat.random(in: 10...100))
myArr.append(new)
}
.onChange(of: myArr) { _ in
withAnimation {
scrollProxy.scrollTo(myArr.last!.id, anchor: .trailing)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
}
}
}

Updating the contents of an array from a different view

I'm writing a macOS app in Swiftui, for Big Sur and newer. It's a three pane navigationview app, where the left most pane has the list of options (All Notes in this case), the middle pane is a list of the actual items (title and date), and the last one is a TextEditor where the user adds text.
Each pane is a view that calls the the next view via a NavigationLink. Here's the basic code for that.
struct NoteItem: Codable, Hashable, Identifiable {
let id: Int
var text: String
var date = Date()
var dateText: String {
dateFormatter.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return dateFormatter.string(from: date)
}
var tags: [String] = []
}
struct ContentView: View {
#State var selection: Set<Int> = [0]
var body: some View {
NavigationView {
List(selection: self.$selection) {
NavigationLink(destination: AllNotes()) {
Label("All Notes", systemImage: "doc.plaintext")
}
.tag(0)
}
.listStyle(SidebarListStyle())
.frame(minWidth: 100, idealWidth: 150, maxWidth: 200, maxHeight: .infinity)
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct AllNotes: View {
#State var items: [NoteItem] = {
guard let data = UserDefaults.standard.data(forKey: "notes") else { return [] }
if let json = try? JSONDecoder().decode([NoteItem].self, from: data) {
return json
}
return []
}()
#State var noteText: String = ""
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: NoteView()) {
VStack(alignment: .leading) {
Text(item.text.components(separatedBy: NSCharacterSet.newlines).first!)
Text(item.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 8)
}
}
.listStyle(InsetListStyle())
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle("A title")
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: {
NewNote()
}) {
Image(systemName: "square.and.pencil")
}
}
}
}
struct NoteView: View {
#State var text: String = ""
var body: some View {
HStack {
VStack(alignment: .leading) {
TextEditor(text: $text).padding().font(.body)
.onChange(of: text, perform: { value in
print("Value of text modified to = \(text)")
})
Spacer()
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}
When I create a new note, how can I save the text the user added on the TextEditor in NoteView in the array loaded in AllNotes so I could save the new text? Ideally there is a SaveNote() function that would happen on TextEditor .onChange. But again, given that the array lives in AllNotes, how can I update it from other views?
Thanks for the help. Newbie here!
use EnvironmentObject in App
import SwiftUI
#main
struct NotesApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(DataModel())
}
}
}
now DataModel is a class conforming to ObservableObject
import SwiftUI
final class DataModel: ObservableObject {
#AppStorage("notes") public var notes: [NoteItem] = []
}
any data related stuff should be done in DataModel not in View, plus you can access it and update it from anywhere, declare it like this in your ContentView or any child View
NoteView
import SwiftUI
struct NoteView: View {
#EnvironmentObject private var data: DataModel
var note: NoteItem
#State var text: String = ""
var body: some View {
HStack {
VStack(alignment: .leading) {
TextEditor(text: $text).padding().font(.body)
.onChange(of: text, perform: { value in
guard let index = data.notes.firstIndex(of: note) else { return }
data.notes[index].text = value
})
Spacer()
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
.onAppear() {
print(data.notes.count)
}
}
}
AppStorage is the better way to use UserDefaults but AppStorage does not work with custom Objects yet (I think it does for iOS 15), so you need to add this extension to make it work.
import SwiftUI
struct NoteItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
var date = Date()
var dateText: String {
let df = DateFormatter()
df.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return df.string(from: date)
}
var tags: [String] = []
}
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}
Now I changed AllNotes view to work with new changes
struct AllNotes: View {
#EnvironmentObject private var data: DataModel
#State var noteText: String = ""
var body: some View {
NavigationView {
List(data.notes) { note in
NavigationLink(destination: NoteView(note: note)) {
VStack(alignment: .leading) {
Text(note.text.components(separatedBy: NSCharacterSet.newlines).first!)
Text(note.dateText).font(.body).fontWeight(.light)
}
.padding(.vertical, 8)
}
}
.listStyle(InsetListStyle())
Text("Select a note...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("A title")
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: {
data.notes.append(NoteItem(id: UUID(), text: "New Note", date: Date(), tags: []))
}) {
Image(systemName: "square.and.pencil")
}
}
}
}
}

SwiftUI Animations in the same view are displayed differently

In the first section there is no problem, but in the second section there is a problem in my animation. using the same view in the first section and the second section.
In the second section, the text shifts to the right and left. But I don't want that. I want the text to remain stable just like in the first section.
SectionView:
(option == 1) = image on the left, text on the right.
(option == 2) = image on the right, text on the left.
struct SectionView: View {
var title: String
var imageView: AnyView
var description: String
var option: Int
var body: some View {
Group {
if option == 1 {
HStack {
ZStack {
Rectangle()
.frame(width: 125, height: 120)
.foregroundColor(Color(UIColor.secondarySystemBackground))
.cornerRadius(40, corners: [.topRight, .bottomRight])
imageView
}
Spacer()
VStack {
Text(title)
.font(.system(size: 14, weight: .bold, design: .rounded))
Text(description)
.multilineTextAlignment(.center)
.font(.system(size: 12, weight: .medium, design: .rounded))
.padding(.trailing, 5)
}
.frame(height: 120)
.foregroundColor(Color(UIColor.label))
}
} else if option == 2 {
HStack {
VStack {
Text(title)
.font(.system(size: 14, weight: .bold, design: .rounded))
Text(description)
.multilineTextAlignment(.center)
.font(.system(size: 12, weight: .medium, design: .rounded))
.padding(.leading, 5)
}
.frame(height: 120)
.foregroundColor(Color(UIColor.label))
Spacer()
ZStack {
Rectangle()
.frame(width: 125, height: 120)
.foregroundColor(Color(UIColor.secondarySystemBackground))
.cornerRadius(40, corners: [.topLeft, .bottomLeft])
imageView
}
}
}
}
}
}
MainViewModel:
I am adding sections here.
As you can see I'm using the same view in the two CategorySections I've added. In the first section, the animation is displayed properly, but while the animation is displayed in the second section, the text shifts to the right and left. What is the reason for this?
class MainViewModel: ObservableObject {
#Published var section: [CategorySection] = []
init() {
getSections()
}
func getSections() {
section = [
CategorySection(title: "Trafik Levhaları", imageView: AnyView(PoliceSignsSectionIconView()), description: "Trafik levhaları trafiğe çıkan her sürücünün mutlaka dikkat etmesi gereken uyarı işaretleridir. Trafik kurallarını ve gerekliliklerini uygulama açısından önemli bir yeri olan trafik işaretleri mutlaka izlenmeli. Bu bölümde trafik levha işaretlerinin anlamlarını öğrenebilirsiniz.", option: 1, page: TrafficSignsView()),
CategorySection(title: "Polis İşaretleri", imageView: AnyView(PoliceSignsSectionIconView()), description: "Trafik polisleri gerektiği durumlarda yolda trafiğin kontrolünü sağlar. Çoğu yerde karşımıza çıkabilecek bu durumda trafik polisi işaretlerinin anlamını bilmemiz gerekmektedir. Bu bölümde polis işaretlerinin anlamlarını öğrenebilirsiniz.", option: 2, page: PoliceSignsView()),
....
]
}
}
CategorySection Model:
struct CategorySection {
let id = UUID()
let title: String
let imageView: AnyView
let description: String
let option: Int
let page: Any
}
Using SectionView:
I am using the sections I added in MainViewModel here.
struct MainView: View {
#ObservedObject var mainViewModel: MainViewModel = MainViewModel()
#EnvironmentObject var publishObjects: PublishObjects
var body: some View {
NavigationView {
ScrollView(showsIndicators: false) {
VStack(spacing: 30) {
ForEach(mainViewModel.section, id: \.id) { section in
NavigationLink(
destination: AnyView(_fromValue: section.page)?.navigationBarTitleDisplayMode(.inline),
label: {
SectionView(title: section.title, imageView: section.imageView, description: section.description, option: section.option)
})
}
}
}
.navigationBarTitle("Ehliyet Sınavım")
.onAppear {
}
}
.accentColor(Color(UIColor.label))
}
}
PoliceSignsSectionIconView(Animation here):
I applied my animations on this page.
struct PoliceSignsSectionIconView: View {
#State var isDrawing: Bool = true
let pathBounds = UIBezierPath.calculateBounds(paths: [.policePath1, .policePath2, .policePath3, .policePath4, .policePath5, .policePath6, .policePath7, .policePath8, .policePath9, .policePath10])
var body: some View {
Text("Test Icon")
.frame(width: 75, height: 70)
.opacity(isDrawing ? 1 : 0)
.onAppear {
self.isDrawing.toggle()
}
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true))
}
}
I solved my problem. I don't know why my problem was solved when I added List object.
MainView:
struct MainView: View {
#ObservedObject var mainViewModel: MainViewModel = MainViewModel()
#EnvironmentObject var publishObjects: PublishObjects
var body: some View {
NavigationView {
List(mainViewModel.section, id: \.id) { section in
NavigationLink(
destination: AnyView(_fromValue: section.page)?.navigationBarTitleDisplayMode(.inline),
label: {
SectionView(title: section.title, imageView: section.imageView, description: section.description, sectionStatus: section.sectionStatus)
})
}
.navigationBarTitle("Ehliyet Sınavım")
}
.accentColor(Color(UIColor.label))
}
}

How do I switch between screens in TabView and from the latter to the other View?

I created a simple collection with a button jump to the next View. From the last View there should be a transition to AddItemView, but it doesn't happen - it goes back to the first screen.
Can you tell me where I made a mistake?
What is the correct way to place the background Image on the first collection screen, so that it won't be on the following screens?
import SwiftUI
struct AddItemView: View {
var body: some View {
Text("Hallo!")
}
}
struct ContentView: View {
var colors: [Color] = [ .orange, .green, .yellow, .pink, .purple ]
var emojis: [String] = [ "👻", "🐱", "🦊" , "👺", "🎃"]
#State private var tabSelection = 0
var body: some View {
TabView(selection: $tabSelection) {
ForEach(0..<emojis.endIndex) { index in
VStack {
Text(emojis[index])
.font(.system(size: 150))
.frame(minWidth: 30, maxWidth: .infinity, minHeight: 0, maxHeight: 250)
.background(colors[index])
.clipShape(RoundedRectangle(cornerRadius: 30))
.padding()
.tabItem {
Text(emojis[index])
}
Button(action: {
self.tabSelection += 1
}) {
if tabSelection == emojis.endIndex {
NavigationLink(destination: AddItemView()) {
Text("Open View")
}
} else {
Text("Change to next tab")
}
}
}
}
}
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
.tabViewStyle(PageTabViewStyle.init(indexDisplayMode: .never))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
In this code, you have not to use NavigationView. It's required to navigate to the next screen. Similar concept like Push view controller if navigation controller exists. Also, remove endIndex and use indices.
struct ContentView: View {
var colors: [Color] = [ .orange, .green, .yellow, .pink, .purple ]
var emojis: [String] = [ "👻", "🐱", "🦊" , "👺", "🎃"]
#State private var tabSelection = 0
var body: some View {
NavigationView { //<- add navigation view
TabView(selection: $tabSelection) {
ForEach(emojis.indices) { index in //<-- use indices
VStack {
Text(emojis[index])
.font(.system(size: 150))
.frame(minWidth: 30, maxWidth: .infinity, minHeight: 0, maxHeight: 250)
.background(colors[index])
.clipShape(RoundedRectangle(cornerRadius: 30))
.padding()
.tabItem {
Text(emojis[index])
}
Button(action: {
self.tabSelection += 1
}) {
if tabSelection == emojis.count - 1 { //<- use count
NavigationLink(destination: AddItemView()) {
Text("Open View")
}
} else {
Text("Change to next tab")
}
}
}
}
}
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
.tabViewStyle(PageTabViewStyle.init(indexDisplayMode: .never))
}
}
}
If you have already a navigation link from the previous screen then, the problem is you are using endIndex in the wrong way. Check this thread for correct use (https://stackoverflow.com/a/36683863/14733292).

In a MacOS App using SwiftUI, how to modify the default blue background of a selected NavigationLink nested in a list?

Good day all. In a MacOS App using SwiftUI, how to modify the default blue background of a selected NavigationLink nested in a list? The list is inside a NavigationView. I could not find the solution here. What would be the code to add to the following basic exemple: Two TextView listed, and if we click on the TextView we display the correspondant View.
ContentView.swift:
import SwiftUI
struct ContentView: View {
#State var selection: Int?
var body: some View {
HStack() {
NavigationView {
List () {
NavigationLink(destination: FirstView(), tag: 0, selection: self.$selection) {
Text("Click Me To Display The First View")
} // End Navigation Link
NavigationLink(destination: SecondView(), tag: 1, selection: self.$selection) {
Text("Click Me To Display The Second View")
} // End Navigation Link
} // End list
.frame(minWidth: 350, maxWidth: 350)
.onAppear {
self.selection = 0
}
} // End NavigationView
.listStyle(SidebarListStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
} // End HStack
} // End some View
} // End ContentView
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
FirstView.swift:
import SwiftUI
struct FirstView: View {
var body: some View {
Text("(1) Hello, I am the first view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct FirstView_Previews: PreviewProvider {
static var previews: some View {
FirstView()
}
}
SecondView.swift:
import SwiftUI
struct SecondView: View {
var body: some View {
Text("(2) Hello, I am the second View")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct SecondView_Previews: PreviewProvider {
static var previews: some View {
SecondView()
}
}
Here is the result if we click on the first row... the background is blue when selected! How to change this default color? Thanks in advance for your help.
the following code is one option to achieve what you want:
struct ContentView: View {
#State var selection: Int? = 0
#State var pressed: Int? = 0
init() {
UITableViewCell.appearance().selectionStyle = .none
UITableView.appearance().backgroundColor = UIColor.clear
}
var body: some View {
var theBinding = Binding(
get: { self.selection },
set: {
self.selection = $0
self.pressed = $0 == nil ? self.pressed : $0!
})
return HStack() {
NavigationView {
List {
NavigationLink(destination: FirstView(), tag: 0, selection: theBinding) {
Text("Click Me To Display The First View")
}.listRowBackground(self.pressed == 0 ? Color.red : Color.green)
NavigationLink(destination: SecondView(), tag: 1, selection: theBinding) {
Text("Click Me To Display The Second View")
}.listRowBackground(self.pressed == 1 ? Color.red : Color.green)
}.frame(minWidth: 350, maxWidth: 350)
.onAppear { self.pressed = 0 }
}.listStyle(SidebarListStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

Resources