How to prevent Array from crashing while simultaneously adding and deleting rows? - swift

I have this circles array that results in Circles being created at random positions, and then fading out. Upon a circle faded out I need to completely delete it, which is achieved by removing the corresponding row in the array after a delay equal to the end of the fade-out animation.
It works fine as long as I click the "add circle" button less frequently than the deletion speed. However, when I keep pressing it fast, the view crashes with an index out of range. This appears to be the result of the algorithm getting confused: it identifies the row to be deleted by its index, but in the meantime other rows could have been deleted/added, which means indexes are already different.
Please see:
import SwiftUI
let screenW = UIScreen.main.bounds.size.width
let screenH = UIScreen.main.bounds.size.height
struct PlayGround: View {
struct BlackCircle {
var id = UUID()
var position: CGPoint
var opacity: Double = 1
}
#State var circles: [BlackCircle] = []
var body: some View {
ZStack {
ForEach(Array(circles.enumerated()), id: \.1.id) {
index, item in
Circle()
.frame(width:30, height:30)
.position(item.position)
.opacity(item.opacity)
.onAppear {
let animation = Animation.linear(duration: 1)
withAnimation(animation) {
circles[index].opacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
circles.remove(at: index)
}
}
}.frame(width: screenW, height:screenH)
Button(action: {
let randx = CGFloat.random(in: 30...screenW-30)
let randy = CGFloat.random(in: 30...screenH-30)
let randpos = CGPoint(x: randx, y: randy)
circles.append(BlackCircle(position: randpos))
}, label: {
Text("Add a Circle")
})
}
}
}
The easiest and no-effort way to solve this is to just not remove the row when the circle has faded out. This is surely both inelegant and inefficient though (would possibly end up with thousands of useless rows in the array). So how would I go about preventing the array from crashing when new elements are being added and delay-deleted all the time?
Thanks!

Just use following delete method without using indexes
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
circles.removeAll { (circle) -> Bool in
circle.id == item.id
}
}

If the problem is due to the index being out of range, try removing the circles based on id instead of index:
circles.removeAll { (existingCircle) -> Bool in
return existingCircle.id == item.id
}

Related

Random modify state after animation completion

I'd like to be able to change the state of a variable once an animation is complete and then redraw using the new value.
For example, in the code below, I'd like the Text to rotate a different amount each time the animation cycle completes.
struct RotateView: View {
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever()) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}
}
struct RotateView_Previews: PreviewProvider {
static var previews: some View {
RotateView()
}
}
I understand that within the withAnimation body closure that I'm only setting the state to animate between and this is only executed once - thus, a random amount is calculated and the text rotates that amount forever.
I've looked at this excellent post that adds an onAnimationCompleted but it seems like overkill for my problem. In addition, while it sort of worked, the callback was called many times after each animation completed and randomly set the variable many times - causing the Text to rotate very quickly until the animation kicked off again?
if you can stick to the defined duration of 2 secs, you can combine the .onAppear animation with additional animations kicked off by a timer that fires every two seconds.
let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
.onReceive(timer) { time in
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}

SwiftUI What exactly happens when use .offset and .position Modifier simultaneously on a View, which decides the final location?

Here I have this question when I try to give a View an initial position, then user can use drag gesture to change the location of the View to anywhere. Although I already solved the issue by only using .position(x:y:) on a View, at the beginning I was thinking using .position(x:y:) to give initial position and .offset(offset:) to make the View move with gesture, simultaneously. Now, I really just want to know in more detail, what exactly happens when I use both of them the same time (the code below), so I can explain what happens in the View below.
What I cannot explain in the View below is that: when I simply drag gesture on the VStack box, it works as expected and the VStack moves with finger gesture, however, once the gesture ends and try to start a new drag gesture on the VStack, the VStack box goes back to the original position suddenly (like jumping to the original position when the code is loaded), then start moving with the gesture. Note that the gesture is moving as regular gesture, but the VStack already jumped to a different position so it starts moving from a different position. And this causes that the finger tip is no long on top of the VStack box, but off for some distance, although the VStack moves with the same trajectory as drag gesture does.
My question is: why the .position(x:y:) modifier seems only take effect at the very beginning of each new drag gesture detected, but during the drag gesture action on it seems .offset(offset:) dominates the main movement and the VStack stops at where it was dragged to. But once new drag gesture is on, the VStack jumps suddenly to the original position. I just could not wrap my head around how this behavior happens through timeline. Can somebody provide some insights?
Note that I already solved the issue to achieve what I need, right now it's just to understand what is exactly going on when .position(x:y:) and .offset(offset:) are used the same time, so please avoid some advice like. not use them simultaneously, thank you. The code bellow suppose to be runnable after copy and paste, if not pardon me for making mistake as I delete few lines to make it cleaner to reproduce the issue.
import SwiftUI
struct ContentView: View {
var body: some View {
ButtonsViewOffset()
}
}
struct ButtonsViewOffset: View {
let location: CGPoint = CGPoint(x: 50, y: 50)
#State private var offset = CGSize.zero
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
self.offset = value.translation
print("offset onChange: \(offset)")
}
.onEnded{ _ in
if self.color == Color.purple{
self.color = Color.blue
}
else{
self.color = Color.purple
}
}
}
var body: some View {
VStack {
Text("Watch 3-1")
Text("x: \(self.location.x), y: \(self.location.y)")
}
.background(Color.gray)
.foregroundColor(self.color)
.offset(self.offset)
.position(x: self.location.x, y: self.location.y)
.gesture(dragGesture)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
}
}
}
Your issue has nothing to do with the use of position and offset. They actually both work simultaneously. Position sets the absolute position of the view, where as offset moves it relative to the absolute position. Therefore, you will notice that your view starts at position (50, 50) on the screen, and then you can drag it all around. Once you let go, it stops wherever it was. So far, so good. You then want to move it around again, and it pops back to the original position. The reason it does that is the way you set up location as a let constant. It needs to be state.
The problem stems from the fact that you are adding, without realizing it, the values of offset to position. When you finish your drag, offset retains the last values. However, when you start your next drag, those values start at (0,0) again, therefore the offset is reset to (0,0) and the view moves back to the original position. The key is that you need to use just the position or update the the offset in .onEnded. Don't use both. Here you have a set position, and are not saving the offset. How you handle it depends upon the purpose for which you are moving the view.
First, just use .position():
struct OffsetAndPositionView: View {
#State private var position = CGPoint(x: 50, y: 50)
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
position = value.location
print("position onChange: \(position)")
}
.onEnded{ value in
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.position(position)
.gesture(dragGesture)
}
}
Second, just use .offset():
struct ButtonsViewOffset: View {
#State private var savedOffset = CGSize.zero
#State private var dragValue = CGSize.zero
#State private var color = Color.purple
var offset: CGSize {
savedOffset + dragValue
}
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
dragValue = value.translation
print("dragValue onChange: \(dragValue)")
}
.onEnded{ value in
savedOffset = savedOffset + value.translation
dragValue = CGSize.zero
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.offset(offset)
.gesture(dragGesture)
}
}
// Convenience operator overload
func + (lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}

View don't update in real time when running a cycle

I'm making a card game in SwiftUI and having the following problem: when running a cycle, the view updates only on cycle stop, but don't show any changes when running. UI part of code is:
//on the table
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 0) {
if game.gameStarted {
ForEach ((0..<game.onTheTable.count), id: \.self) {number in
VStack {
Image(game.onTheTable[number].pic)
.resizable()
.modifier(CardStyle())
Text("\(ai.getPower(card: game.onTheTable[number]))")
}
}
}
}
}
It actually shows card images "on the table" when I move an item to the game.onTheTable array. But when I run a while loop like "while true" it behaves as I mentioned above. So I've created a simple code with a delay to be able to se how card images one by one appears on the table but it just doesn't work as expected. There's the code for the cycle:
func test() {
gameStarted = true
while deck.cardsInDeck.count > 0 {
onTheTable.append(deck.cardsInDeck[0])
deck.cardsInDeck.remove(at: 0)
usleep(100000)
}
}
Yes, it appends cards, but visually I see the result just when the whole cycle has finished. Any ideas how to fix that to see the cards being added in real time one by one?
SwiftUI is declarative, so it doesn't mesh well with imperative control flow like while loops or system timers. You don't have control over when layout happens. Instead, you need to modify the underlying state which is driving the view, and those updates must happen on the main thread.
Here's one approach, which starts the timer when the view appears. You could also trigger the timer based on user interaction.
Note that you can attach transitions to views, and those transitions can take advantage of .matchedGeometryEffect... So you could have cards animate from their position on the deck to their place on the table, and that could happen automatically as you move items from one array to another—as long as the deck and table views use the same namespace and a consistent ID for each unique card.
struct GameView: View {
#State var deckCards: [Card] = Card.standardDeck
#State var tableCards: [Card] = []
#State var timer: Timer? = nil
var body: some View {
VStack {
DeckView(cards: deckCards)
TableView(cards: tableCards)
}.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
moveCard()
}
}
}
func moveCard() {
DispatchQueue.main.async {
guard deckCards.count > 0 else {
self.timer?.invalidate()
return
}
tableCards.append(deckCards.removeFirst())
}
}
}

How to use user input in function in swiftui?

I have a View, in which the user is able to enter some text into a TextField. I want to be able to get the text, which was entered in the TextField and use this value inside of a struct. The concept of the app, is that it shows the elevation degree of the sun. To be able to do this, it is scraping the values from a WebPage. However to make this app dynamic, the user has to be able to edit the url (you can change location, date etc in the url). I thought this would be fairly easy, since I only have to get some text, and edit a url before the url is being loaded. I have been able to pass the value into a view, however I need it in a struct. Maybe the whole "layout of my code is wrong, maybe I should get the data and draw the function in a view? I don't know. This is my first time coding with swift.
I want to change the latitude var.
This is my code:
View 1 (Input):
class ViewModel: ObservableObject {
#Published var latitude:String = ""
#Published var page = 0
}
struct ContentView: View {
#EnvironmentObject var value1: ViewModel
var body: some View {
if value1.page == 0{
VStack{
TextField("", text: $value1.latitude)
Button(action:{ value1.page = 1}){
Text("To next view")
}.frame(width: 300, height: 100, alignment: .center)
}
} else {
elevationGraph()
}
}
}
View 2 (Graph)
struct getHtml {
var url = URL(string: "https://midcdmz.nrel.gov/apps/spa.pl?syear=2020&smonth=1&sday=1&eyear=2020&emonth=1&eday=1&otype=0&step=60&stepunit=1&hr=12&min=0&sec=0&latitude=\(latitude)&longitude=10.757933&timezone=1.0&elev=53&press=835&temp=10&dut1=0.0&deltat=64.797&azmrot=180&slope=0&refract=0.5667&field=0")
func loadData(from url: URL?) -> String {
guard let url = url else {
return "nil"
}
let html = try! String(contentsOf: url, encoding: String.Encoding.utf8)
return html
}
}
struct elevationFunction: Shape {
var url: URL? //This only works in views, is there a way to do it in shape structs?
let html = getHtml.init().loadData(from: getHtml.init().url)
private func dbl1() -> Double {
let leftSideOfTheValue = "0:00:00,"
let rightSideOfTheValue = "\(month)/\(day)/\(year),1:00:00,"
guard let leftRange = html.range(of: leftSideOfTheValue) else {
print("cant find left range")
return 0
}
guard let rightRange = html.range(of: rightSideOfTheValue) else {
print("cant find right range")
return 0
}
let rangeOfTheValue = leftRange.upperBound..<rightRange.lowerBound
return Double(html[rangeOfTheValue].dropLast()) ?? 90
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 10, y: (125 - (90-dbl1()))))
path.addLine(to: CGPoint(x: 120, y: (125 - (90-45))))
path.addLine(to: CGPoint(x: 250, y: (125 - (90-dbl1()))))
var scale = (rect.height / 350) * (9/10)
var xOffset = (rect.width / 6)
var yOffset = (rect.height / 2)
return path.applying(CGAffineTransform(scaleX: scale, y: scale)).applying(CGAffineTransform(translationX: xOffset, y: yOffset))
}
}
struct elevationGraph: View {
var body: some View {
GeometryReader { geometry in
ZStack {
elevationFunction().stroke(LinearGradient(gradient: Gradient(colors: [Color.yellow, Color.red]), startPoint: .top , endPoint: .bottom), style: StrokeStyle(lineWidth: 6.0)).aspectRatio(contentMode: .fill)
}
.frame(width: 600, height: 800, alignment: .center)
}
}
}
As mentioned in my comment, you can pass a parameter to a Shape just like you can a regular View:
elevationFunction(url: yourURL)
Best practice would be to capitalize this and name it ...Shape as well:
elevationFunction becomes ElevationShape
Regarding your second question in the comment, first, you may want to fix the naming of getHtml for the same reason as above -- uncapitalized, it looks like a variable name. Maybe something like DataLoader.
Regarding the crash, you have some circular logic going on -- you call getHtml.init() and then pass a parameter that is again derived from getHtml.init() again. Why not just call getHtml() and have it loadData from its own internal URL property?
There's a larger problem at work, though, which is that you've declared html as a let property on your Shape, which is going to get recreated every time your Shape is rendered. So, on every render, with your current code, you'll create 2 new getHtmls and attempt to load the data (which very well may not actually have time to load the URL request). This very well could be blocking the first render of the Shape as well and is almost certainly causing your crash somewhere in the circular and repetitive logic going on.
Instead, you might want to consider moving your URL request to onAppear or as part of an ObservableObject where you can have a little more control of when and how often this data gets loaded. Here's a good resource on learning more about loading data using URLSession and SwiftUI: https://www.hackingwithswift.com/books/ios-swiftui/sending-and-receiving-codable-data-with-urlsession-and-swiftui

UITesting Xcode 7: How to tell if XCUIElement is visible?

I am automating an app using UI Testing in Xcode 7. I have a scrollview with XCUIElements (including buttons, etc) all the way down it. Sometimes the XCUIElements are visible, sometimes they hidden too far up or down the scrollview (depending on where I am on the scrollview).
Is there a way to scroll items into view or maybe tell if they are visible or not?
Thanks
Unfortunately Apple hasn't provided any scrollTo method or a .visible parameter on XCUIElement. That said, you can add a couple helper methods to achieve some of this functionality. Here is how I've done it in Swift.
First for checking if an element is visible:
func elementIsWithinWindow(element: XCUIElement) -> Bool {
guard element.exists && !CGRectIsEmpty(element.frame) && element.hittable else { return false }
return CGRectContainsRect(XCUIApplication().windows.elementBoundByIndex(0).frame, element.frame)
}
Unfortunately .exists returns true if an element has been loaded but is not on screen. Additionally we have to check that the target element has a frame larger than 0 by 0 (also sometimes true) - then we can check if this frame is within the main window.
Then we need a method for scrolling a controllable amount up or down:
func scrollDown(times: Int = 1) {
let topScreenPoint = app.mainWindow().coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.05))
let bottomScreenPoint = app.mainWindow().coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.90))
for _ in 0..<times {
bottomScreenPoint.pressForDuration(0, thenDragToCoordinate: topScreenPoint)
}
}
func scrollUp(times: Int = 1) {
let topScreenPoint = app.mainWindow().coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.05))
let bottomScreenPoint = app.mainWindow().coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.90))
for _ in 0..<times {
topScreenPoint.pressForDuration(0, thenDragToCoordinate: bottomScreenPoint)
}
}
Changing the CGVector values for topScreenPoint and bottomScreenPoint will change the scale of the scroll action - be aware if you get too close to the edges of the screen you will pull out one of the OS menus.
With these two methods in place you can write a loop that scrolls to a given threshold one way until an element becomes visible, then if it doesn't find its target it scrolls the other way:
func scrollUntilElementAppears(element: XCUIElement, threshold: Int = 10) {
var iteration = 0
while !elementIsWithinWindow(element) {
guard iteration < threshold else { break }
scrollDown()
iteration++
}
if !elementIsWithinWindow(element) { scrollDown(threshold) }
while !elementIsWithinWindow(element) {
guard iteration > 0 else { break }
scrollUp()
iteration--
}
}
This last method isn't super efficient, but it should at least enable you to find elements off screen. Of course if you know your target element is always going to be above or below your starting point in a given test you could just write a scrollDownUntil or a scrollUpUntill method without the threshold logic here.
Hope this helps!
Swift 5 Update
func elementIsWithinWindow(element: XCUIElement) -> Bool {
guard element.exists && !element.frame.isEmpty && element.isHittable else { return false }
return XCUIApplication().windows.element(boundBy: 0).frame.contains(element.frame)
}
func scrollDown(times: Int = 1) {
let mainWindow = app.windows.firstMatch
let topScreenPoint = mainWindow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.05))
let bottomScreenPoint = mainWindow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.90))
for _ in 0..<times {
bottomScreenPoint.press(forDuration: 0, thenDragTo: topScreenPoint)
}
}
func scrollUp(times: Int = 1) {
let mainWindow = app.windows.firstMatch
let topScreenPoint = mainWindow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.05))
let bottomScreenPoint = mainWindow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.90))
for _ in 0..<times {
topScreenPoint.press(forDuration: 0, thenDragTo: bottomScreenPoint)
}
}
func scrollUntilElementAppears(element: XCUIElement, threshold: Int = 10) {
var iteration = 0
while !elementIsWithinWindow(element: element) {
guard iteration < threshold else { break }
scrollDown()
iteration += 1
}
if !elementIsWithinWindow(element: element) {
scrollDown(times: threshold)
}
while !elementIsWithinWindow(element: element) {
guard iteration > 0 else { break }
scrollUp()
iteration -= 1
}
}
What i had to do to address this problem is to actually swipe up or down in my UI testing code. Have you tried this?
XCUIApplication().swipeUp()
Or you can also do WhateverUIElement.swipUp() and it will scroll up/down with respect to that element.
Hopefully they will fix the auto scroll or auto find feature so we don't have to do this manually.
You should check isHittable property.
If view is not hidden, the corresponding XCUIElement is hittable. But there is a caveat. "View 1" can be overlapped by "View 2", but the element corresponding to "View 1" can be hittable.
Since you have some XCUIElements in the bottom of the tableview (table footer view), the way of scrolling the tableview all the way to the bottom in the UI test, supposing your tableview has a lot data, is by tap().
.swipeUp() also does the job but the problem is when your test data is huge, it takes forever to swipe, as oppose to .tap() which directly jumps to the bottom of the tableView.
More specially:
XCUIElementsInTheBottomOrTableFooterView.tap()
XCTAssert(XCUIElementsInTheBottomOrTableFooterView.isHittable, "message")
Looks like this is a known bug :-(
https://forums.developer.apple.com/thread/9934