SwiftUI list deletion - swift

I am facing problem, where i cannot bind Object properly in swiftUI. what I want to do is animate when user tap deletion.
ScrollView(.horizontal, showsIndicators: false) {
HStack{
VStack{
ZStack(alignment: .center) {
ForEach(viewModel.cards) { card in
let deleteAction: (Int) -> Void = { index in
withAnimation {
viewModel.deleteCard(at: index) // this is where i want to animate changes
}
}
StatusCardView(cardViewModel: card, editMode: $editMode, shrinked: $shrinked, scale: $scaleFactor, onDelete: deleteAction)
.animation(.spring(), value: viewModel.cards)
}
}
.frame(width: 1, height: 1)
Spacer()
}
Spacer()
}
}
viewModel:
class BubblesViewModel: ObservableObject {
#Published private(set) var cards = [CardViewModel]() // it is filled in constructor (not important right now)
func deleteCard(at index: Int) {
cards.removeAll(where: {index == $0.index})
freeCards = cards
cards.removeAll()
layoutCards()
}
}
StatusCardView:
struct StatusCardView: View {
...
var onDeleteClosure: ((Int) -> Void)
init(cardViewModel: CardViewModel, editMode: Binding<Bool>, shrinked: Binding<Bool>, scale: Binding<CGFloat>, onDelete: #escaping ((Int) -> Void)) {
...
self.onDeleteClosure = onDelete
}
var body: some View {
ZStack {
...
ZStack {
if editMode {
Text("-")
.font(type.titleFont)
.frame(width: 15, height: 15, alignment: .center)
.foregroundColor(.white)
.background(Color.Card.foregroundOutline)
.cornerRadius(50)
.opacity(editMode ? 1 : 0)
.onTapGesture {
onDeleteClosure(model.index!) // closure is called here to move logic to ViewModel
}
}
}
Spacer()
}
Spacer()
}
}
}
}
}
when I click delete nothing happens. animation is performed only on scroll. Any ideas what seems to be a problem ? edit: I updated post and added code for StatusCardView

Related

SwiftUI: Publishing changes from async model

I'm attempting to figure out how to display a message to my users when some asynchronous code takes some time to run. So far I've used a sample I found online to create a popup banner and tied the message together using an ObservedObject of the async method on my view and then Publish the values from my async method.
My sample code project is on a public GitHub repository here and I'll post the code at the bottom.
Right now I have an issue when setting the variables from the async method: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Solutions online seem to fix this issue by updating the value on the #mainActor thread but I want these methods to run asynchronously AND update the user on what's happening. What's the best way to update my variables from this location?
CODE
in the main app:
var body: some Scene {
WindowGroup {
ContentView(asyncmethod: myAsyncViewModel())
}
}
ContentView:
import SwiftUI
struct ContentView: View {
#State private var isLoaderPresented = false
#State private var isTopMessagePresented = false
#ObservedObject var asyncmethod: myAsyncViewModel
var body: some View {
VStack {
Spacer()
Button( action: {
Task {
isTopMessagePresented = true
let response = await asyncmethod.thisMethodTakesTime()
// Want to return a string or object so I know what happens.
print("Response Loader: \(response ?? "no response")")
isTopMessagePresented = false
}
},
label: { Text("Run Top Banner Code") }
)
Spacer()
}
.foregroundColor(.black)
.popup(isPresented: isTopMessagePresented, alignment: .top, direction: .top, content: {
Snackbar(showForm: $isTopMessagePresented, asyncmethod: asyncmethod)
})
}
}
struct Snackbar: View {
#Binding var showForm: Bool
#ObservedObject var asyncmethod: myAsyncViewModel
var body: some View {
VStack {
HStack() {
Image(systemName: asyncmethod.imageName)
.resizable()
.aspectRatio(contentMode: ContentMode.fill)
.frame(width: 40, height: 40)
Spacer()
VStack(alignment: .center, spacing: 4) {
Text(asyncmethod.title)
.foregroundColor(.black)
.font(.headline)
Text(asyncmethod.subTitle)
.font(.body)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 200)
}
.padding(15)
.frame(maxWidth: .infinity, idealHeight: 100)
.background(Color.black.opacity(0.1))
}
}
My async sample method:
import Foundation
class myAsyncViewModel: ObservableObject {
#Published var imageName: String = "questionmark"
#Published var title: String = "title"
#Published var subTitle: String = "subtitle"
func thisMethodTakesTime() async -> String? {
print("In method: \(imageName), \(title), \(subTitle)")
title = "MY METHOD"
subTitle = "Starting out!"
print("In method. Starting \(title)")
subTitle = "This is the message"
print("Sleeping")
try? await Task.sleep(nanoseconds: 1_000_000_000)
subTitle = "Between"
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("After sleep. Ending")
subTitle = "About to return. Success!"
print("In method: \(imageName), \(title), \(subTitle)")
return "RETURN RESULT"
}
}
And the supporting file for the popup:
import SwiftUI
struct Popup<T: View>: ViewModifier {
let popup: T
let isPresented: Bool
let alignment: Alignment
let direction: Direction
// 1.
init(isPresented: Bool, alignment: Alignment, direction: Direction, #ViewBuilder content: () -> T) {
self.isPresented = isPresented
self.alignment = alignment
self.direction = direction
popup = content()
}
// 2.
func body(content: Content) -> some View {
content
.overlay(popupContent())
}
// 3.
#ViewBuilder private func popupContent() -> some View {
GeometryReader { geometry in
if isPresented {
withAnimation {
popup
.transition(.offset(x: 0, y: direction.offset(popupFrame: geometry.frame(in: .global))))
.frame(width: geometry.size.width, height: geometry.size.height, alignment: alignment)
}
}
}
}
}
extension Popup {
enum Direction {
case top, bottom
func offset(popupFrame: CGRect) -> CGFloat {
switch self {
case .top:
let aboveScreenEdge = -popupFrame.maxY
return aboveScreenEdge
case .bottom:
let belowScreenEdge = UIScreen.main.bounds.height - popupFrame.minY
return belowScreenEdge
}
}
}
}
private extension GeometryProxy {
var belowScreenEdge: CGFloat {
UIScreen.main.bounds.height - frame(in: .global).minY
}
}
extension View {
func popup<T: View>(
isPresented: Bool,
alignment: Alignment = .center,
direction: Popup<T>.Direction = .bottom,
#ViewBuilder content: () -> T
) -> some View {
return modifier(Popup(isPresented: isPresented, alignment: alignment, direction: direction, content: content))
}
}
Again all this can be found in my GitHub page here.
You can annotate the observable class or just the function with ‘#MainActor’ or use DispatchQueue.main.async when you assign to the published variables.

AnyTransition issue with simple view update in macOS

I have 2 user view called user1 and user2, I am updating user with button, and I want give a transition animation to update, but for some reason my transition does not work, as I wanted, the issue is there that Text animated correctly but image does not, it stay in its place and it does not move with Text to give a smooth transition animation.
struct ContentView: View {
#State var show: Bool = Bool()
var body: some View {
VStack {
if (show) {
UserView(label: { Text("User 1") })
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: Edge.trailing), removal: AnyTransition.move(edge: Edge.leading)))
}
else {
UserView(label: { Text("User 2") })
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: Edge.leading), removal: AnyTransition.move(edge: Edge.trailing)))
}
Button("update") { show.toggle() }
}
.padding()
.animation(Animation.linear(duration: 1.0), value: show)
}
}
struct UserView<Label: View>: View {
let label: () -> Label
#State private var heightOfLabel: CGFloat? = nil
var body: some View {
HStack {
if let unwrappedHeight: CGFloat = heightOfLabel {
Image(systemName: "person")
.resizable()
.frame(width: unwrappedHeight, height: unwrappedHeight)
}
label()
.background(GeometryReader { proxy in
Color.clear
.onAppear(perform: { heightOfLabel = proxy.size.height })
})
Spacer(minLength: CGFloat.zero)
}
.animation(nil, value: heightOfLabel)
}
}
the heightOfLabel doesn't have to be optional, and then it works:
struct UserView<Label: View>: View {
let label: () -> Label
#State private var heightOfLabel: CGFloat = .zero // not optional
var body: some View {
HStack {
Image(systemName: "person")
.resizable()
.frame(width: heightOfLabel, height: heightOfLabel)
label()
.background(GeometryReader { proxy in
Color.clear
.onAppear(perform: { heightOfLabel = proxy.size.height })
})
Spacer(minLength: CGFloat.zero)
}
.animation(nil, value: heightOfLabel)
}
}

SwiftUI For Each Button in list - not working... sometimes

I have made a custom selection button view in SwiftUI for an app that is being developed. I cant for the life of me work out why sometimes the buttons don't do anything - It is always the last x number of buttons that don't work (which made me think it was related to the 10 view limitation of swift ui however, I've been told this isn't an issue when using a for each loop).
Sometimes it works as expected and others it cuts off the last x number of buttons. Although when it is cutting off buttons it is consistent between different simulators and physical devices. Can anybody see anything wrong here?
I am new to SwiftUI and so could be something simple...
#EnvironmentObject var QuestionManager: questionManager
var listItems: [String]
#State var selectedItem: String = ""
var body: some View {
GeometryReader {geom in
ScrollView{
VStack{
ForEach(Array(listItems.enumerated()), id: \.offset){ item in
Button(action: {
if (selectedItem != item.element) {
selectedItem = item.element
} else {
selectedItem = ""
QuestionManager.tmpAnswer = ""
}
}, label: {
GeometryReader { g in
Text("\(item.element)")
.font(.system(size: g.size.width/22))
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.black)
.lineLimit(2)
.frame(width: g.size.width, height: g.size.height)
.minimumScaleFactor(0.5)
.background(
Rectangle()
.fill((item.element == selectedItem) ? Color(.green) : .white)
.frame(width: g.size.width, height: g.size.height)
.border(Color.gray)
).scaledToFit()
}
.frame(width: geom.size.width*0.92, height: 45)
}).disabled((Int(QuestionManager.answers.year) == Calendar.current.component(.year, from: Date())) ? validateMonth(month: item.offset) : false)
}
}
.frame(width: geom.size.width)
}
}
}
} ```
As #Yrb mentioned, using enumerated() is not a great option in a ForEach loop.
Your issue could be compounded by listItems having duplicate elements.
You may want to restructure your code, something like this approach using a dedicated
item structure, works very well in my tests:
struct MyItem: Identifiable, Equatable {
let id = UUID()
var name = ""
init(_ str: String) {
self.name = str
}
static func == (lhs: MyItem, rhs: MyItem) -> Bool {
lhs.id == rhs.id
}
}
struct ContentView: View {
#EnvironmentObject var QuestionManager: questionManager
// for testing
var listItems: [MyItem] = [MyItem("1"),MyItem("2"),MyItem("3"),MyItem("4"),MyItem("6"),MyItem("7"),MyItem("8"),MyItem("9")]
#State var selectedItem: MyItem? = nil
var body: some View {
GeometryReader {geom in
ScrollView{
VStack{
ForEach(listItems){ item in
Button(action: {
if (selectedItem != item) {
selectedItem = item
} else {
selectedItem = nil
QuestionManager.tmpAnswer = ""
}
}, label: {
GeometryReader { g in
Text(item.name)
.font(.system(size: g.size.width/22))
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.black)
.lineLimit(2)
.frame(width: g.size.width, height: g.size.height)
.minimumScaleFactor(0.5)
.background(
Rectangle()
.fill((item == selectedItem) ? Color(.green) : .white)
.frame(width: g.size.width, height: g.size.height)
.border(Color.gray)
).scaledToFit()
}
.frame(width: geom.size.width*0.92, height: 45)
})
.disabled((Int(QuestionManager.answers.year) == Calendar.current.component(.year, from: Date())) ? validateMonth(item: item) : false)
}
}
.frame(width: geom.size.width)
}
}
}
func validateMonth(item: MyItem) -> Bool {
if let itemOffset = listItems.firstIndex(where: {$0.id == item.id}) {
// ... do your validation
return true
}
return false
}
}

SwiftUI - How to limit the scope of animation to only the onAppear Transition

I'm new to SwiftUI and working through some sample projects to get the hang of it and I'm getting stuck on limiting the scope of the animation I set for the .transition for an AnimationModifier so it only impacts the animation of the transition and nothing else in the view.
While the separate transitions are respected for onAppear() and another for onDisappear(). The animation in the AnimatableModifier is overriding the removal over the item from the grid even when explicitly declared
I've tried explcicitly setting the Animation to the .offset transition in both the AnimatableModifier and for the CardView in the GameView, and when I do, no animation is triggered at all:
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)).animation(Animation.easeInOut(duration: 1.25).delay(delay)))
So, there's gotta be a way to limit the scope or explicitly declare the animation for transition or two separate Animations in the Animation Modifier, but I'm not finding any resources on how to move forward.
GameView.swift
struct GameView: View {
#ObservedObject var viewModel: SetGameViewModel
#State var delay: Double = 0.1
var body: some View {
GeometryReader { geometry in
VStack {
Grid(newItems: self.viewModel.newCards,
items: self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay)) { card in
CardView(card: card, bodyGeoProxy: geometry, delay: self.delay).onTapGesture {
self.viewModel.choose(card: card)
}
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-50)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
.onAppear() {
let maxDelay: Double = Double(self.viewModel.cards.itemsAtWithIds(ids: self.viewModel.idOfCardsToDisplay).count)*0.2 + 0.2
if self.delay < 2.5 {
self.delay = self.delay + 0.2
} else if self.delay >= maxDelay {
self.delay = 0.1
}
}
}
HStack{
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("Hit Me")
}
Spacer()
Text("Score: \(self.viewModel.score)")
Spacer()
Button(action: {
self.viewModel.dealThreeCards()
}) {
Text("New Game")
}
}
}
}
}
}
GameView.swift
struct CardView: View{
var card: SetGame<SoloSetCardContent>.Card
var bodyGeoProxy: GeometryProxy
var delay: Double
var body: some View {
GeometryReader { geometry in
self.body(for: geometry)
}
}
init(card: SetGame<SoloSetCardContent>.Card, bodyGeoProxy: GeometryProxy, delay: Double) {
self.card = card
self.bodyGeoProxy = bodyGeoProxy
self.delay = delay
}
#ViewBuilder
func body(for geometryProxy: GeometryProxy) -> some View {
ZStack {
if card.isSelected {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.blue, width: 2)
.animation(nil)
} else {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray)
.frame(width: geometryProxy.size.width-4, height: geometryProxy.size.height-4, alignment: .center)
.border(Color.red, width: 2)
.animation(nil)
}
VStack {
ForEach(0..<self.card.content.deckShapes.count) { index in
VStack {
Spacer(minLength: 5)
ShapeView(setShape: self.card.content.deckShapes[index])
.frame(width: (geometryProxy.size.width-geometryProxy.size.width/5), height: geometryProxy.size.height/5, alignment: .center)
Spacer(minLength: 5)
}
}
}
}
.deal(delay: self.delay, offset: bodyGeoProxy.size)
}
}
Dealer.Swift - AnimatableModifier
struct Dealer: AnimatableModifier {
#State var show: Bool = false
var delay: Double
var offset: CGSize
var randomXLocation: CGFloat {
CGFloat.random(in: -offset.width ..< offset.width)
}
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(AnyTransition.offset(CGSize.init(width: randomXLocation, height: -offset.height-450)))
.animation(Animation.easeInOut(duration: 1.25).delay(delay))
}
}
.onAppear {
withAnimation {
self.show = true
}
}
.onDisappear {
withAnimation {
self.show = false
}
}
}
}
extension View {
func deal(delay: Double, offset: CGSize) -> some View {
self.modifier(Dealer(delay: delay, offset: offset))
}
}
I was able to resolve this by removing the Animation from the body content (and elsewhere) and adding to withAnimation portion of the .onAppear method in body function of the AnimationModifier
func body(content: Content) -> some View {
ZStack {
if show {
content
.transition(.asymmetric(insertion: .offset(CGSize.init(width: randomXLocation, height: -offset.height-50)),
removal: .offset(CGSize.init(width: randomXLocation, height: offset.height+50))))
}
}
.onDisappear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(0)) {
self.show = false
}
}
.onAppear {
withAnimation (Animation.easeInOut(duration: 1.25).delay(self.delay)) {
self.show = true
}
}
}

Locating position of another View in SwiftUI

I am building a card game (Set) and when dealing the cards I am currently using
withAnimation{} and .transition(.move(edge: .bottom)
to have the cards animate from the bottom edge of the screen when the user taps the deal button. I want to make it so that the cards 'fly out' of the button and was wondering how I find the location of the button.
Once I find the position of the button I intend to use .offset to have them 'fly' out. Is there a better way?
I know it is possible using geometry reader, but I have not found out how to do so.
Here is my current code
struct SetGameView: View {
#ObservedObject var viewModel: SetViewModel = SetViewModel()
var location: (CGFloat, CGFloat) = (0.0, 0.0)
var body: some View {
VStack {
HStack {
Label("Score: \(viewModel.score)", systemImage: "face.dashed").font(.title).padding(.leading)
Spacer()
}
Grid(viewModel.dealtCards) { card in
CardView(card: card)
.transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .leading)))
.padding()
.onTapGesture { withAnimation { viewModel.choose(card) } }
} .onAppear { withAnimation(.easeInOut) { deal(12) } }
Divider()
HStack {
CreateNewGameButton(viewModel: viewModel)
DealNewCardbutton(viewModel: viewModel, deal: deal)
}.padding(.horizontal).frame(maxHeight: 50)
}
}
struct DealNewCardbutton: View {
var viewModel: SetViewModel
let deal: (Int) -> Void
init(viewModel: SetViewModel, deal: #escaping (Int) -> Void) {
self.viewModel = viewModel
self.deal = deal
}
var body: some View {
GeometryReader { geo in
Button(action: {
deal(3)
}){
ZStack {
RoundedRectangle(cornerRadius: 10.0).foregroundColor(.blue)
Text("Deal Three Cards").foregroundColor(.white)
}
}.onAppear {
print(geo.frame(in: .global).midX, geo.frame(in: .global).midY)
}
}
}
}
Heres a video of how it currently works.
https://i.imgur.com/NJfOjBP.mp4
I want the cards to all 'fly out' from the deal button.
Ok. This is pretty tricky. You can get the position of the button via GeometryGetter, but I'd recommend using anchorPreference. The only downside is that you'll have to specify your grid in an overlay or background of the view containing the button.
import SwiftUI
struct BoundsPreferenceKey: PreferenceKey {
static var defaultValue: [Int: Anchor<CGRect>] = [:]
static func reduce(value: inout [Int: Anchor<CGRect>], nextValue: () -> [Int: Anchor<CGRect>]) {
value.merge(nextValue()) { v1, _ in v1 }
}
}
extension View {
func reportBounds(id: Int) -> some View {
self.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds, transform: { [id: $0] })
}
}
private extension Int {
static let dealButtonId = 0
}
var body: some View {
VStack {
Divider()
HStack {
CreateNewGameButton(viewModel: viewModel)
DealNewCardbutton(viewModel: viewModel, deal: deal)
.reportBounds(id: .dealButtonId)
} .padding(.horizontal).frame(maxHeight: 50)
} .overlayPreferenceValue(BoundsPreferenceKey.self) { preferences in
GeometryReader { geometry in
self.getOverlay(preferences: preferences, geometry: geometry)
}
}
}
func getOverlay(preferences:(preferences : [Int: Anchor<CGRect>], geometry: GeometryProxy) -> some View {
var center: CGPoint = .zero
if let anchor = preferences[.dealButtonId] {
let rect = geometry[anchor]
center = CGPoint(x: rect.midX, y: rect.midY)
}
return VStack {
HStack {
Label("Score: \(viewModel.score)", systemImage: "face.dashed").font(.title).padding(.leading)
Spacer()
}
Grid(viewModel.dealtCards) { card in
CardView(card: card)
// USE CENTER here in your transition and/or offset
.transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .leading)))
.padding()
.onTapGesture { withAnimation { viewModel.choose(card) } }
} .onAppear { withAnimation(.easeInOut) { deal(12) } }
Spacer()
}
}
Note that I moved the grid and other views to the overlay, you may need to set explicit frame heights (to the spacer) to make sure the bottom of the grid and the top of the button align. I left the actual implementation of the transition for you.