Animation bug when swiping UIView - ios

I'm developing an app which has a 3 view and which is a card view like in Tinder. I'm creating views in a for loop. When I have more than 4 views, everything works fine. When It has only 3 cards, everything looks okey at first ,when the app opens, but after swiping one card, It gets broken. Last card moves with some bug. I'm trying to edit the code to work with 3 card but can't figure out. By the way, ImageCard is just a UIView class.
EDIT: My problem is that when It has 3 cards, App opens with 3 cards shown on screen but after a swipe, last card doesn't show on the screen, only 2 cards shown in screen. After swipe card on the front should goes to backmost and 3 cards should be seen again. When It has more than 5 cards, everything works fine like I explained and 3 cards shown on screen (What It needs to be)
I'm sure showNextCard() function occurs the problem but to be sure here is the full code :
class WelcomeViewController: UIViewController {
/// Data structure for custom cards
var cards = [ImageCard]()
override func viewDidLoad() {
super.viewDidLoad()
dynamicAnimator = UIDynamicAnimator(referenceView: self.view)
print(self.view.frame.height)
print(self.view.frame.width)
let screenWidth = self.view.frame.width
let screenHeight = self.view.frame.height
//When add new cards to self.cards and call layoutCards() again
for i in 1...5 {
let card = ImageCard(frame: CGRect(x: 0, y: 0, width: screenWidth - screenWidth / 5, height: screenWidth))
card.tag = i
card.label.text = "Card Number: \(i)"
cards.append(card)
}
lastIndex = cards.count
// 2. layout the first cards for the user
layoutCards()
}
/// Scale and alpha of successive cards visible to the user
let cardAttributes: [(downscale: CGFloat, alpha: CGFloat)] = [(1, 1), (0.92, 0.8), (0.84, 0.6), (0.76, 0.4)]
let cardInteritemSpacing: CGFloat = 12
/// Set up the frames, alphas, and transforms of the first 4 cards on the screen
func layoutCards() {
// frontmost card (first card of the deck)
let firstCard = cards[0]
self.view.addSubview(firstCard)
firstCard.layer.zPosition = CGFloat(cards.count)
firstCard.center = self.view.center
firstCard.frame.origin.y += 23
firstCard.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handleCardPan)))
// the next 3 cards in the deck
for i in 1...3 {
if i > (cards.count - 1) { continue }
let card = cards[i]
card.layer.zPosition = CGFloat(cards.count - i)
// here we're just getting some hand-picked vales from cardAttributes (an array of tuples)
// which will tell us the attributes of each card in the 4 cards visible to the user
let downscale = cardAttributes[i].downscale
let alpha = cardAttributes[i].alpha
card.transform = CGAffineTransform(scaleX: downscale, y: downscale)
card.alpha = alpha
// position each card so there's a set space (cardInteritemSpacing) between each card, to give it a fanned out look
card.center.y = self.view.center.y + 23
card.frame.origin.x = cards[0].frame.origin.x + (CGFloat(i) * cardInteritemSpacing * 3)
// workaround: scale causes heights to skew so compensate for it with some tweaking
if i == 3 {
card.frame.origin.x += 1.5
}
self.view.addSubview(card)
}
// make sure that the first card in the deck is at the front
self.view.bringSubview(toFront: cards[0])
}
/// This is called whenever the front card is swiped off the screen or is animating away from its initial position.
/// showNextCard() just adds the next card to the 4 visible cards and animates each card to move forward.
func showNextCard() {
let animationDuration: TimeInterval = 0.2
// 1. animate each card to move forward one by one
for i in 1...3{
if i > (cards.count - 1) { continue }
let card = cards[i]
let newDownscale = cardAttributes[i - 1].downscale
let newAlpha = cardAttributes[i - 1].alpha
UIView.animate(withDuration: animationDuration, delay: (TimeInterval(i - 1) * (animationDuration / 2)), usingSpringWithDamping: 0.8, initialSpringVelocity: 0.0, options: [], animations: {
card.transform = CGAffineTransform(scaleX: newDownscale, y: newDownscale)
card.alpha = newAlpha
if i == 1 {
card.center = self.view.center
card.frame.origin.y += 23
} else {
card.center.y = self.view.center.y + 23
card.frame.origin.x = self.cards[1].frame.origin.x + (CGFloat(i - 1) * self.cardInteritemSpacing * 3)
}
}, completion: { (_) in
if i == 1 {
card.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.handleCardPan)))
}
})
}
// 2. add a new card (now the 4th card in the deck) to the very back
if 4 > (cards.count - 1) {
if cards.count != 1 {
self.view.bringSubview(toFront: cards[1])
}else{
//self.view.bringSubview(toFront: cards.last!)
}
return
}
let newCard = cards[4]
newCard.layer.zPosition = CGFloat(cards.count - 4)
let downscale = cardAttributes[3].downscale
let alpha = cardAttributes[3].alpha
// initial state of new card
newCard.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
newCard.alpha = 0
newCard.center.y = self.view.center.y + 23
newCard.frame.origin.x = cards[1].frame.origin.x + (4 * cardInteritemSpacing * 3)
self.view.addSubview(newCard)
// animate to end state of new card
UIView.animate(withDuration: animationDuration, delay: (3 * (animationDuration / 2)), usingSpringWithDamping: 0.8, initialSpringVelocity: 0.0, options: [], animations: {
newCard.transform = CGAffineTransform(scaleX: downscale, y: downscale)
newCard.alpha = alpha
newCard.center.y = self.view.center.y + 23
newCard.frame.origin.x = self.cards[1].frame.origin.x + (3 * self.cardInteritemSpacing) + 1.5
}, completion: { (_) in
})
// first card needs to be in the front for proper interactivity
self.view.bringSubview(toFront: self.cards[1])
}
/// Whenever the front card is off the screen, this method is called in order to remove the card from our data structure and from the view.
func removeOldFrontCard() {
cards.append(cards[0])
cards[0].removeFromSuperview()
cards.remove(at: 0)
layoutCards()
}
private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool {
let translation = recognizer.translation(in: self.view!)
if fabs(translation.y) > fabs(translation.x) {
return true
}
return false
}
/// UIKit dynamics variables that we need references to.
var dynamicAnimator: UIDynamicAnimator!
var cardAttachmentBehavior: UIAttachmentBehavior!
/// This method handles the swiping gesture on each card and shows the appropriate emoji based on the card's center.
#objc func handleCardPan(sender: UIPanGestureRecognizer) {
// Ensure it's a horizontal drag
let velocity = sender.velocity(in: self.view)
if abs(velocity.y) > abs(velocity.x) {
return
}
// if we're in the process of hiding a card, don't let the user interace with the cards yet
if cardIsHiding { return }
// change this to your discretion - it represents how far the user must pan up or down to change the option
// distance user must pan right or left to trigger an option
let requiredOffsetFromCenter: CGFloat = 80
let panLocationInView = sender.location(in: view)
let panLocationInCard = sender.location(in: cards[0])
switch sender.state {
case .began:
dynamicAnimator.removeAllBehaviors()
let offset = UIOffsetMake(cards[0].bounds.midX, panLocationInCard.y)
// card is attached to center
cardAttachmentBehavior = UIAttachmentBehavior(item: cards[0], offsetFromCenter: offset, attachedToAnchor: panLocationInView)
//dynamicAnimator.addBehavior(cardAttachmentBehavior)
let translation = sender.translation(in: self.view)
print(sender.view!.center.x)
if(sender.view!.center.x < 555) {
sender.view!.center = CGPoint(x: sender.view!.center.x + translation.x, y: sender.view!.center.y)
}else {
sender.view!.center = CGPoint(x:sender.view!.center.x, y:554)
}
sender.setTranslation(CGPoint(x: 0, y: 0), in: self.view)
case .changed:
//cardAttachmentBehavior.anchorPoint = panLocationInView
let translation = sender.translation(in: self.view)
print(sender.view!.center.y)
if(sender.view!.center.x < 555) {
sender.view!.center = CGPoint(x: sender.view!.center.x + translation.x, y: sender.view!.center.y)
}else {
sender.view!.center = CGPoint(x:sender.view!.center.x, y:554)
}
sender.setTranslation(CGPoint(x: 0, y: 0), in: self.view)
case .ended:
dynamicAnimator.removeAllBehaviors()
if !(cards[0].center.x > (self.view.center.x + requiredOffsetFromCenter) || cards[0].center.x < (self.view.center.x - requiredOffsetFromCenter)) {
// snap to center
let snapBehavior = UISnapBehavior(item: cards[0], snapTo: CGPoint(x: self.view.frame.midX, y: self.view.frame.midY + 23))
dynamicAnimator.addBehavior(snapBehavior)
} else {
let velocity = sender.velocity(in: self.view)
let pushBehavior = UIPushBehavior(items: [cards[0]], mode: .instantaneous)
pushBehavior.pushDirection = CGVector(dx: velocity.x/10, dy: velocity.y/10)
pushBehavior.magnitude = 175
dynamicAnimator.addBehavior(pushBehavior)
// spin after throwing
var angular = CGFloat.pi / 2 // angular velocity of spin
let currentAngle: Double = atan2(Double(cards[0].transform.b), Double(cards[0].transform.a))
if currentAngle > 0 {
angular = angular * 1
} else {
angular = angular * -1
}
let itemBehavior = UIDynamicItemBehavior(items: [cards[0]])
itemBehavior.friction = 0.2
itemBehavior.allowsRotation = true
itemBehavior.addAngularVelocity(CGFloat(angular), for: cards[0])
dynamicAnimator.addBehavior(itemBehavior)
showNextCard()
hideFrontCard()
}
default:
break
}
}
/// This function continuously checks to see if the card's center is on the screen anymore. If it finds that the card's center is not on screen, then it triggers removeOldFrontCard() which removes the front card from the data structure and from the view.
var cardIsHiding = false
func hideFrontCard() {
if #available(iOS 10.0, *) {
var cardRemoveTimer: Timer? = nil
cardRemoveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (_) in
guard self != nil else { return }
if !(self!.view.bounds.contains(self!.cards[0].center)) {
cardRemoveTimer!.invalidate()
self?.cardIsHiding = true
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn], animations: {
self?.cards[0].alpha = 0.0
}, completion: { (_) in
self?.removeOldFrontCard()
self?.cardIsHiding = false
})
}
})
} else {
// fallback for earlier versions
UIView.animate(withDuration: 0.2, delay: 1.5, options: [.curveEaseIn], animations: {
self.cards[0].alpha = 0.0
}, completion: { (_) in
self.removeOldFrontCard()
})
}
}
}
ImageCard Class:
class ImageCard: UIView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
// card style
self.backgroundColor = UIColor.blue
self.layer.cornerRadius = 26
label.font = Font.gothamBold?.withSize(30)
label.textColor = UIColor.white
self.addSubview(label)
label.anchor(self.topAnchor, left: self.leftAnchor, bottom: nil, right: nil, topConstant: 0, leftConstant: 0, bottomConstant: 0, rightConstant: 0, widthConstant: 0, heightConstant: 0)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

I found you forget to turn off your dynamicAnimator after animations. At least, you need to turn off animator about cards[0]. Otherwise, it becomes unpredictable. You can use your removeOldFrontCard() like this. Hope this is the answer.
func removeOldFrontCard() {
dynamicAnimator.removeAllBehaviors()
cards.append( cards.remove(at: 0))
layoutCards()
}

You start at index 1 but index of an Array starts with 0
// the next 3 cards in the deck
for i in 1...3 {
if i > (cards.count - 1) { continue }
let card = cards[i]
...
}
Change that to:
// the next 3 cards in the deck
for i in 0...2 {
if i > (cards.count - 1) { break }
let card = cards[i]
...
}

Related

Completion block of animation is performed immediately

I'm trying to remove the custom view from the superview after the end of the animation in the completion block, but it is called immediately and the animation becomes sharp. I managed to solve the problem in a not very good way: just adding a delay to remove the view.
Here is the function for animating the view:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
}
The problem in this line: self.soundView.removeFromSuperview()
When I call this function in the switch recognizer.state completion block statement it executes early and when elsewhere everything works correctly.
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
case .changed:
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
if notHiddenSoundViewRect.minX > soundView.frame.minX {
animatedHideSoundView(toRight: false)
} else if notHiddenSoundViewRect.maxX < soundView.frame.maxX {
animatedHideSoundView(toRight: true)
}
case .ended, .cancelled:
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(
x: soundView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: soundView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestCornerPosition = nearestCorner(to: projectedPosition)
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: soundView.center.x, to: nearestCornerPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: soundView.center.y, to: nearestCornerPosition.y)
)
let timingParameters = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
animator.addAnimations {
self.soundView.center = nearestCornerPosition
}
animator.startAnimation()
default: break
}
}
I want the user to be able to swipe this soundView off the screen.
That's why I check where the soundView is while the user is moving it, so that if he moves the soundView near the edge of the screen, I can hide the soundView animatedly.
Maybe I'm doing it wrong, but I couldn't think of anything else, because I don't have much experience. Could someone give me some advice on this?
I managed to solve it this way, but I don't like it:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
enter image description here
You can see and run all code here: https://github.com/swiloper/AnimationProblem
Couple notes...
First, in your controller code, you are calling animatedHideSoundView() from your pan gesture recognizer every time you move the touch. It's unlikely that's what you want to do.
Second, if you call animatedHideSoundView(toRight: true) your code:
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? 0.0 : -screenWidth
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
self.songPlayer.pause()
}
}
}
sets translationX to Zero ... when you then try to animate the transform, the animation will take no time because you're not changing the x.
Third, I strongly suggest that you start simple. The code you linked to cannot be copy/pasted/run, which makes it difficult to offer help.
Here's a minimal version of your UniversalTypesViewController class (it uses your linked SoundView class):
final class UniversalTypesViewController: UIViewController {
// MARK: Properties
private lazy var soundView = SoundView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
private let panGestureRecognizer = UIPanGestureRecognizer()
private var initialOffset: CGPoint = .zero
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
panGestureRecognizer.addTarget(self, action: #selector(soundViewPanned(recognizer:)))
soundView.addGestureRecognizer(panGestureRecognizer)
}
private func animatedShowSoundView() {
// reset soundView's transform
soundView.transform = .identity
// add it to the view
view.addSubview(soundView)
// position soundView near bottom, but past the right side of view
soundView.frame.origin = CGPoint(x: view.frame.width, y: view.frame.height - soundView.frame.height * 2.0)
soundView.startSoundBarsAnimation()
// animate soundView into view
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseOut) {
self.soundView.transform = CGAffineTransform(translationX: -self.soundView.frame.width * 2.0, y: 0.0)
}
}
private func animatedHideSoundView(toRight: Bool) {
let translationX = toRight ? view.frame.width : -(view.frame.width + soundView.frame.width)
UIView.animate(withDuration: 0.5) {
self.soundView.transform = CGAffineTransform(translationX: translationX, y: 0.0)
} completion: { isFinished in
if isFinished {
self.soundView.removeFromSuperview()
//self.songPlayer.pause()
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// if soundView is not in the view hierarchy,
// animate it into view - animatedShowSoundView() func adds it as a subview
if soundView.superview == nil {
animatedShowSoundView()
} else {
// unwrap the touch
guard let touch = touches.first else { return }
// get touch location
let loc = touch.location(in: self.view)
// if touch is inside the soundView frame,
// return, so pan gesture can move soundView
if soundView.frame.contains(loc) { return }
// if touch is on the left-half of the screen,
// animate soundView to the left and remove after animation
if loc.x < view.frame.midX {
animatedHideSoundView(toRight: false)
} else {
// touch is on the right-half of the screen,
// so just remove soundView
animatedHideSoundView(toRight: true)
}
}
}
// MARK: Objc methods
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
case .changed:
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
case .ended, .cancelled:
()
default: break
}
}
}
If you run that, tapping anywhere will animate soundView into view at bottom-right. You can then drag soundView around.
If you tap away from soundView frame, on the left-half of the screen, soundView will be animated out to the left and removed after animation completes.
If you tap away from soundView frame, on the right-half of the screen, soundView will be animated out to the right and removed after animation completes.
Once you've got that working, and you see what's happening, you can implement it in the rest of your much-more-complex code.
Edit
Take a look at this modified version of your code.
One big problem in your code is that you're making multiple calls to animatedHideSoundView(). When the drag gets near the edge, your code calls that... but then it gets called again because the drag is still "active."
So, I added a var isHideAnimationRunning: Bool flag so calls to positioning when dragging and positioning when "hide" animating don't conflict.
A few other changes:
instead of mixing Transforms with .center positioning, get rid of the Transforms and just use .center
I created a struct with logically named corner points - makes it much easier to reference them
strongly recommended: add comments to your code!
So, give this a try:
import UIKit
let screenWidth: CGFloat = UIScreen.main.bounds.width
let screenHeight: CGFloat = UIScreen.main.bounds.height
let sideSpacing: CGFloat = 32.0
let mediumSpacing: CGFloat = 16.0
var isNewIphone: Bool {
return screenHeight / screenWidth > 1.8
}
extension CGPoint {
func distance(to point: CGPoint) -> CGFloat {
return sqrt(pow(point.x - x, 2) + pow(point.y - y, 2))
}
}
// so we can refer to corner positions by logical names
struct CornerPoints {
var topLeft: CGPoint = .zero
var bottomLeft: CGPoint = .zero
var bottomRight: CGPoint = .zero
var topRight: CGPoint = .zero
}
final class ViewController: UIViewController {
private var cornerPoints = CornerPoints()
private let soundViewSide: CGFloat = 80.0
private lazy var halfSoundViewWidth = soundViewSide / 2
private lazy var newIphoneSpacing = isNewIphone ? mediumSpacing : 0.0
private lazy var soundView = SoundView(frame: CGRect(origin: .zero, size: CGSize(width: soundViewSide, height: soundViewSide)))
private lazy var notHiddenSoundViewRect = CGRect(x: mediumSpacing, y: 0.0, width: screenWidth - mediumSpacing * 2, height: screenHeight)
private var initialOffset: CGPoint = .zero
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .yellow
// setup corner points
let left = sideSpacing + halfSoundViewWidth
let right = view.frame.maxX - (sideSpacing + halfSoundViewWidth)
let top = sideSpacing + halfSoundViewWidth - newIphoneSpacing
let bottom = view.frame.maxY - (sideSpacing + halfSoundViewWidth - newIphoneSpacing)
cornerPoints.topLeft = CGPoint(x: left, y: top)
cornerPoints.bottomLeft = CGPoint(x: left, y: bottom)
cornerPoints.bottomRight = CGPoint(x: right, y: bottom)
cornerPoints.topRight = CGPoint(x: right, y: top)
let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: #selector(soundViewPanned(recognizer:)))
soundView.addGestureRecognizer(panGestureRecognizer)
// for development, let's add a double-tap recognizer to
// add the soundView again (if it's been removed)
let dt = UITapGestureRecognizer(target: self, action: #selector(showAgain(_:)))
dt.numberOfTapsRequired = 2
view.addGestureRecognizer(dt)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.animatedShowSoundView()
}
}
#objc func showAgain(_ f: UITapGestureRecognizer) {
// if soundView has been removed
if soundView.superview == nil {
// add it
animatedShowSoundView()
}
}
private func animatedShowSoundView() {
// start at bottom-right, off-screen to the right
let pt: CGPoint = cornerPoints.bottomRight
soundView.center = CGPoint(x: screenWidth + soundViewSide, y: pt.y)
view.addSubview(soundView)
soundView.startSoundBarsAnimation()
// animate to bottom-right corner
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseOut) {
self.soundView.center = pt
}
}
// flag so we know if soundView is currently
// "hide" animating
var isHideAnimationRunning: Bool = false
private func animatedHideSoundView(toRight: Bool) {
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
// set flag to true
isHideAnimationRunning = true
// target center X
let targetX: CGFloat = toRight ? screenWidth + soundViewSide : -soundViewSide
UIView.animate(withDuration: 0.5) {
self.soundView.center.x = targetX
} completion: { isFinished in
self.isHideAnimationRunning = false
if isFinished {
self.soundView.removeFromSuperview()
//self.songPlayer.pause()
}
}
}
}
#objc private func soundViewPanned(recognizer: UIPanGestureRecognizer) {
let touchPoint = recognizer.location(in: view)
switch recognizer.state {
case .began:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
initialOffset = CGPoint(x: touchPoint.x - soundView.center.x, y: touchPoint.y - soundView.center.y)
}
case .changed:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
soundView.center = CGPoint(x: touchPoint.x - initialOffset.x, y: touchPoint.y - initialOffset.y)
if notHiddenSoundViewRect.minX > soundView.frame.minX {
animatedHideSoundView(toRight: false)
} else if notHiddenSoundViewRect.maxX < soundView.frame.maxX {
animatedHideSoundView(toRight: true)
}
}
case .ended, .cancelled:
// only execute if soundView is not currently "hide" animating
if !isHideAnimationRunning {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(
x: soundView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: soundView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestCornerPosition = nearestCorner(to: projectedPosition)
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: soundView.center.x, to: nearestCornerPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: soundView.center.y, to: nearestCornerPosition.y)
)
let timingParameters = UISpringTimingParameters(dampingRatio: 0.8, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters)
animator.addAnimations {
self.soundView.center = nearestCornerPosition
}
animator.startAnimation()
}
default: break
}
}
private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}
private func nearestCorner(to point: CGPoint) -> CGPoint {
var minDistance = CGFloat.greatestFiniteMagnitude
var nearestPosition = CGPoint.zero
for position in [cornerPoints.topLeft, cornerPoints.bottomLeft, cornerPoints.bottomRight, cornerPoints.topRight] {
let distance = point.distance(to: position)
if distance < minDistance {
nearestPosition = position
minDistance = distance
}
}
return nearestPosition
}
/// Calculates the relative velocity needed for the initial velocity of the animation.
private func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {
guard currentValue - targetValue != 0 else { return 0 }
return velocity / (targetValue - currentValue)
}
}

How to combine translate and rotate transform animation in Swift(iOS)?

I created a small UIView object, First I use translate transform move UIView to new location, after animation stop, I want UIView rotate in new location, so the code is:
//circle2 is UIView
UIView.animate(withDuration: 1, delay:0,options:[.curveLinear],animations:{
self.circle2.transform=CGAffineTransform(translationX: 0, y: 35)
},completion: {(result) in
UIView.animate(withDuration: 1, delay:0,options:[.repeat],animations: {
self.circle2.transform=CGAffineTransform(rotationAngle: CGFloat.pi)
//self.circle2.transform=CGAffineTransform(rotationAngle: CGFloat.pi).translatedBy(x: 0, y: 35)
//self.circle2.transform=CGAffineTransform(translationX: 0, y: 35).rotated(by: CGFloat.pi)
//self.circle2.transform=CGAffineTransform(rotationAngle: CGFloat.pi).concatenating(CGAffineTransform(translationX: 0, y: 35))
})
})
But I found that when UIView is rotating, it also move upward to origin position.
I try three another combination method, none of then works....
Two of your examples do work but animation may be different than what you expected. You will need to do it manually. It is painful but please try and examine the following code:
private func startAnimation()
let startTime: Date = .init()
let translationDuration: TimeInterval = 1.0
var rotation: CGFloat = 0.0
let rotationDuration: TimeInterval = 1.0
var translation: CGPoint = .zero
func refreshView(_ controller: ViewController) {
controller.circle2.transform = CGAffineTransform(rotationAngle: rotation).concatenating(CGAffineTransform(translationX: translation.x, y: translation.y))
}
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { timer in
let now: Date = .init()
let scale: Double = (now.timeIntervalSince(startTime))/translationDuration
guard scale > 0.0 else { return } // Waiting part if delay is applied
if scale < 1.0 {
// Animate the view
translation = CGPoint(x: 0.0, y: 35.0*scale)
refreshView(self)
} else {
// Animation ended
timer.invalidate()
translation = CGPoint(x: 0.0, y: 35.0)
refreshView(self)
var rotationStartTime: Date = .init()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
let now: Date = .init()
let scale: Double = (now.timeIntervalSince(rotationStartTime))/rotationDuration
guard scale > 0.0 else { return } // Waiting part if delay is applied
if scale < 1.0 {
// Animate the view
rotation = .pi * CGFloat(scale)
refreshView(self)
} else {
// Animation ended
rotation = 0.0
rotationStartTime = .init()
refreshView(self)
}
}
}
}
}
If you have a need to stop the animation then you will need some references to timers.

iOS Spin UIImageView with deceleration motion at random position

I trying to create a spin wheel which rotate on tap and it rotates for certain period of time and stops at some random circular angle.
import UIKit
class MasterViewController: UIViewController {
lazy var imageView: UIImageView = {
let bounds = self.view.bounds
let v = UIImageView()
v.backgroundColor = .red
v.frame = CGRect(x: 0, y: 0,
width: bounds.width - 100,
height: bounds.width - 100)
v.center = self.view.center
return v
}()
lazy var subView: UIView = {
let v = UIView()
v.backgroundColor = .black
v.frame = CGRect(x: 0, y: 0,
width: 30,
height: 30)
return v
}()
var dateTouchesEnded: Date?
var dateTouchesStarted: Date?
var deltaAngle = CGFloat(0)
var startTransform: CGAffineTransform?
var touchPointStart: CGPoint?
override func viewDidLoad() {
super.viewDidLoad()
self.imageView.addSubview(self.subView)
self.view.addSubview(self.imageView)
self.imageView.isUserInteractionEnabled = true
self.setupGesture()
}
private func setupGesture() {
let gesture = UITapGestureRecognizer(target: self,
action: #selector(handleGesture(_:)))
self.view.addGestureRecognizer(gesture)
}
#objc
func handleGesture(_ sender: UITapGestureRecognizer) {
var timeDelta = 1.0
let _ = Timer.scheduledTimer(withTimeInterval: 0.2,
repeats: true) { (timer) in
if timeDelta < 0 {
timer.invalidate()
} else {
timeDelta -= 0.03
self.spinImage(timeDelta: timeDelta)
}
}
}
func spinImage(timeDelta: Double) {
print("TIME DELTA:", timeDelta)
let direction: Double = 1
let rotation: Double = 1
UIView.animate(withDuration: 5,
delay: 0,
options: .curveEaseOut,
animations: {
let transform = self.imageView.transform.rotated(
by: CGFloat(direction) * CGFloat(rotation) * CGFloat(Double.pi)
)
self.imageView.transform = transform
}, completion: nil)
}
}
I tried via above code, but always stops on initial position
i.e. the initial and final transform is same.
I want it to be random at every time.
One thing you can do is make a counter with a random number within a specified range. When the time fires, call spinImage then decrement the counter. Keep doing that until the counter reaches zero. This random number will give you some variability so that you don't wind up with the same result every time.
#objc func handleGesture(_ sender: UITapGestureRecognizer) {
var counter = Int.random(in: 30...33)
let _ = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { (timer) in
if counter < 0 {
timer.invalidate()
} else {
counter -= 1
self.spinImage()
}
}
}
In spinImage, instead of rotating by CGFloat.pi, rotate by by CGFloat.pi / 2 so that you have four possible outcomes instead of two.
func spinImage() {
UIView.animate(withDuration: 2.5,
delay: 0,
options: .curveEaseOut,
animations: {
let transform = self.imageView.transform.rotated(
by: CGFloat.pi / 2
)
self.imageView.transform = transform
}, completion: nil)
}
You may want to mess around with the counter values, the timer interval, and the animation duration to get the effect that you want. The values I chose here are somewhat arbitrary.

Move spritekit node only within a specific bounds

I'm new to spritekit so this looks like a silly question but I can't figure out. The player (shown in blue circle) can only go above lines and inside the square. I added a joystick, user can go up or down above left line. I want player to be limited to only line so when It comes the left edge, user should move joystick to right. How can I achieve it?
I tried to update player position in override func update(_ currentTime: TimeInterval) function like below to update enum position and check it everytime in move logic;
override func update(_ currentTime: TimeInterval) {
if((player?.position.x)!.rounded() <= self.barra.frame.minX.rounded()){
player?.playerPosition == .left
}
print(player?.position)
}
How I declare square;
let barra = SKShapeNode(rectOf: CGSize(width: 600, height: 300)) //Line
override func sceneDidLoad() {
player = self.childNode(withName: "player") as? Player
player?.physicsBody?.categoryBitMask = playerCategory
player?.physicsBody?.collisionBitMask = noCategory
player?.physicsBody?.contactTestBitMask = enemyCategory | itemCategory
player?.playerPosition = .left
barra.name = "bar"
barra.fillColor = SKColor.clear
barra.lineWidth = 3.0
barra.position = CGPoint(x: 0, y: 0)
self.addChild(barra)
player?.position = CGPoint(x: barra.frame.minX , y: barra.frame.minY)
}
How I move the player;
override func didMove(to view: SKView) {
/* Setup your scene here */
backgroundColor = UIColor.black
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
moveAnalogStick.position = CGPoint(x: moveAnalogStick.radius + 15, y: moveAnalogStick.radius + 15)
addChild(moveAnalogStick)
moveAnalogStick.stick.color = UIColor.white
//MARK: Handlers begin
moveAnalogStick.beginHandler = { [unowned self] in
guard let aN = self.player else {
return
}
//aN.run(SKAction.sequence([SKAction.scale(to: 0.5, duration: 0.5), SKAction.scale(to: 1, duration: 0.5)]))
}
moveAnalogStick.trackingHandler = { [unowned self] data in
guard let aN = self.player else {
return
}
if(self.player?.playerPosition == .left){
aN.position = CGPoint(x: aN.position.x, y: aN.position.y + (data.velocity.y * 0.12))
}
}
moveAnalogStick.stopHandler = { [unowned self] in
guard let aN = self.player else {
return
}
// aN.run(SKAction.sequence([SKAction.scale(to: 1.5, duration: 0.5), SKAction.scale(to: 1, duration: 0.5)]))
}
//MARK: Handlers end
let selfHeight = frame.height
let btnsOffset: CGFloat = 10
let btnsOffsetHalf = btnsOffset / 2
view.isMultipleTouchEnabled = true
}
Player class:
enum Position{
case left
case right
case up
case down
case inside
}
enum CanMove{
case upDown
case leftRight
case all
}
class Player: SKSpriteNode {
var playerSpeed: CGFloat = 0.0
var playerPosition: Position = .left //Default one
var canMove: CanMove = .upDown
func move(){
}
}

CGRect in Swift 3 rotating with correct origin

I'm trying to rotate a line (thin rectangle) around its endpoint. I have it working fine, but step 2 is to shorten the line and have it still rotate around the endpoint (center of rotation).
// Create and add a colored square
var rod = UIView()
var countsteps = 0
var Mass = 1
var Radius = 1.00
let rectWidth = screenWidth * 0.5
let rectVertical = screenHeight * 0.5
let rectLeft = screenWidth * 0.5
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
// set background color to blue
rod.backgroundColor = UIColor(red: 145/255, green: 170/255, blue: 157/255, alpha: 1)
//rod Line
rod.frame = CGRect(x: rectLeft, y: rectVertical, width: 3, height: rectWidth)
// finally, add the square to the screen
self.view.addSubview(rod)
//Create Timer
_ = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(Acceleration.update), userInfo: nil, repeats: true)
}
func update() {
UIView.animate(withDuration: 0.1, animations: {
//Slope Line
let rectSize = CGFloat(self.Radius) * self.rectWidth / 100
let amountRotation = CGFloat(CGFloat(self.countsteps)/57.3)
let originPoint = CGPoint(x: 0,y: 0)
self.rod.layer.anchorPoint = originPoint
self.rod.transform = CGAffineTransform(rotationAngle: amountRotation)
self.countsteps = self.countsteps + 1
if (self.countsteps > 359) {
self.countsteps = 0
}
})
}
I tried adding this line:
self.rod.frame.size = CGSize(width: 3.00, height: rectSize)
But, that makes the box rotate while changing proportions around a different axis.
Try adding this line:
self.rod.layer.position = originPoint
so, your update func looks like:
func update() {
UIView.animate(withDuration: 0.1, animations: {
//Slope Line
let rectSize = CGFloat(self.Radius) * self.rectWidth / 100
let amountRotation = CGFloat(CGFloat(self.countsteps)/57.3)
let originPoint = CGPoint(x: 0,y: 0)
self.rod.layer.anchorPoint = originPoint
self.rod.layer.position = originPoint
self.rod.transform = CGAffineTransform(rotationAngle: amountRotation)
self.countsteps = self.countsteps + 1
if (self.countsteps > 359) {
self.countsteps = 0
}
})
}
I seriously recommend adding some color to layers and views background, with some alpha, it is quite useful to detect what the system is doing, like a visual debug.

Resources