SwiftUI - How to animate components corresponding to array elements? - animation

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

Related

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

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/

#State var doesn't update when changing the value outside the view

I need to declare the checkBox array using "#State" if I want to use it inside the view using $checkBox and it works fine but when I want to update the toggles (1 or more elements of the array) in a function outside the view, the array is not updated. I tried to declare it using #Binding and #Published but without success. I saw many similar Q&A but I didn't find a solution for my case. This is my code:
struct CheckboxStyle: ToggleStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return HStack {
Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(configuration.isOn ? .green : .gray)
.onTapGesture { configuration.isOn.toggle() }
configuration.label
}
}
}
struct ContentView: View {
#State var checkBox = Array(repeating: false, count: 14)
let checkBoxName: [LocalizedStringKey] = ["checkPressure", "checkVisibility", "checkCloudCover", "checkAirTemp", "checkWaterTemp", "checkWindDir", "checkWindSpeed", "checkWindGust", "checkCurrentDir", "checkCurrentSpeed", "checkSwellDir", "checkWaveHeight", "checkWavePeriod", "checkTideHeight"]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Group {
ForEach(0..<7) { t in
Toggle(isOn: $checkBox[t], label: {
Text(checkBoxName[t]).font(.footnote).fontWeight(.light)
}).toggleStyle(CheckboxStyle()).padding(5)
}
}
Group {
ForEach(7..<14) { t in
Toggle(isOn: $checkBox[t], label: {
Text(checkBoxName[t]).font(.footnote).fontWeight(.light)
}).toggleStyle(CheckboxStyle()).padding(5)
}
}
}
}
}
}
Thx for help

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).

swiftui list - snap scrolling

i have a fullscreen list of element.
VStack{
List {
ForEach(books, id: \.id) { book in
Text(book.title)
.background(Color.yellow) // text background
.listRowBackground(Color.blue) // cell background
}
.frame(height: UIScreen.main.bounds.height)
}
}
.background(Color.red)
.edgesIgnoringSafeArea(.all)
Is possible to snap every cell on top when scrolling? I have no idea how to do this.
Thank you!
You can use DragGesture and ScrollViewReader to create a snapping List in SwiftUI.
We are calculating the velocity to move the list.
Here is a full sample Code:
struct SampleSnappingListView: View {
enum ScrollDirection {
case up
case down
case none
}
#State var scrollDirection: ScrollDirection = .none
var body: some View {
VStack {
ScrollViewReader { reader in
List {
ForEach(0...20, id:\.self) { index in
ZStack {
Color((index % 2 == 0) ? .red : .green)
VStack {
Text("Index \(index)")
.font(.title)
}
.frame(height: UIScreen.main.bounds.height/2)
}
.clipShape(Rectangle())
.id(index)
.simultaneousGesture(
DragGesture(minimumDistance: 0.0)
.onChanged({ dragValue in
let isScrollDown = 0 > dragValue.translation.height
self.scrollDirection = isScrollDown ? .down : .up
})
.onEnded { value in
let velocity = CGSize(
width: value.predictedEndLocation.x - value.location.x,
height: value.predictedEndLocation.y - value.location.y
)
if abs(velocity.height) > 100 {
withAnimation(.easeOut(duration: 0.5)) {
let next = index + (scrollDirection == .down ? 1 : -1)
reader.scrollTo(next, anchor: .top)
}
}
}
)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.listStyle(.plain)
}
}
}
}
struct SampleSnappingListView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SampleSnappingListView()
.navigationTitle("Snapping list")
.navigationBarTitleDisplayMode(.inline)
}
}
}

MacOS-style List with Plus and Minus buttons

How can I do something like that in SwiftUI?
Reckon this view is built in Cocoa, since I can't even layout List and GroupBox properly: strange border appears.
There is no Table view in SwiftUI, there is no NSSegmentedControl.
This is the code I got so far:
import SwiftUI
struct DetailView: View {
let text: String
var body: some View {
GroupBox {
Text(text)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.leading, 20)
.frame(width:300, height:300)
}
}
struct ContentView: View {
private let names = ["One", "Two", "Three"]
#State private var selection: String? = "One"
var body: some View {
NavigationView {
List(selection: $selection) {
Section(header:
Text("Header")) {
ForEach(names, id: \.self) { name in
NavigationLink(destination: DetailView(text: name)) {
Text(name)
}
}
}
}.frame(width: 200, height: 300).padding(10).border(Color.green, width: 0)
DetailView(text: self.selection ?? "none selected")
}.padding(10)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
import AppKit
struct ContentView: View {
var body: some View {
HStack(spacing: 20) {
PrinterPicker()
.frame(width: 160)
PrinterDetail()
.frame(width: 320)
} //
.padding()
}
}
struct PrinterPicker: View {
var body: some View {
VStack(spacing: 0) {
PrinterList()
PrinterListToolbar()
} //
.border(Color(NSColor.gridColor), width: 1)
}
}
struct PrinterList: View {
var body: some View {
List {
Text("Printer 1")
.font(Font.system(size: 15))
Text("Printer 2")
.font(Font.system(size: 15))
}
}
}
struct PrinterListToolbar: View {
var body: some View {
HStack(spacing: 0) {
ListButton(imageName: NSImage.addTemplateName)
Divider()
ListButton(imageName: NSImage.removeTemplateName)
Divider()
Spacer()
} //
.frame(height: 20)
}
}
struct ListButton: View {
var imageName: String
var body: some View {
Button(action: {}) {
Image(nsImage: NSImage(named: imageName)!)
.resizable()
} //
.buttonStyle(BorderlessButtonStyle())
.frame(width: 20, height: 20)
}
}
struct PrinterDetail: View {
var body: some View {
HStack {
Spacer()
VStack {
Spacer()
Text("No printers are available.")
Text("Click Add (+) to set up a printer.")
Spacer()
}
Spacer()
} //
.font(Font.system(size: 15))
.background(Color(
NSColor.unemphasizedSelectedContentBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(lineWidth: 1)
.foregroundColor(Color(NSColor.gridColor)))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Resources