Adding a UIPanGestureRecognizer to UITableViewCell causes odd behaviour in iPhone X Landscape - ios

I have some code in my HW Cell as seen below. I am leaving out the extra code that appears to be largely unrelated.
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.recognizer = UIPanGestureRecognizer(target: self, action: #selector(HWCell.handlePan(_:)))
self.recognizer.delegate = self
addGestureRecognizer(self.recognizer)
}
override func awakeFromNib() {
super.awakeFromNib()
self.cardView.layer.masksToBounds = false
self.layer.masksToBounds = false
self.contentView.layer.masksToBounds = false
self.layoutIfNeeded()
}
override func layoutSubviews() {
super.layoutSubviews()
self.cardView.layoutIfNeeded() //Needed for iOS10 since layoutSubviews() reports inaccurate frame sizes.
self.cardView.layer.cornerRadius = self.cardView.frame.size.height / 3
self.cardView.layer.masksToBounds = false
}
//MARK: - horizontal pan gesture methods
func handlePan(_ recognizer: UIPanGestureRecognizer) {
// 1
if recognizer.state == .began {
// when the gesture begins, record the current center location
originalCenter = self.center
//originalCenter = self.cardView.center
self.bringSubview(toFront: self.contentView)
self.originalAlpha = self.cardView.alpha
}
// 2
if recognizer.state == .changed {
let translation = recognizer.translation(in: self)
self.center = CGPoint(x: originalCenter.x + translation.x, y: originalCenter.y)
// 3 //Remembered, state == .Cancelled when recognizer manually disabled.
if recognizer.state == .ended {
// the frame this cell had before user dragged it
let originalFrame = CGRect(x: 0, y: frame.origin.y,
width: bounds.size.width, height: bounds.size.height)
if (!deleteOnDragRelease && !completeOnDrag) {
// if the item is not being deleted, snap back to the original location
UIView.animate(withDuration: 0.2, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: { self.frame = originalFrame }, completion: nil)
if (self.task?.completed == false) {
UIView.transition(with: self.completionImageView,
duration: 0.2,
options: UIViewAnimationOptions.transitionCrossDissolve,
animations: { self.completionImageView.image = UIImage(named: "Grey Checkmark") },
completion: nil)
}
}
What happens is that, and keep in mind that this ONLY HAPPENS on iPhone X in Landscape mode, is the cell extends itself further by stretching instead of simply moving when I drag my finger across it.
You can see this behaviour in a video I uploaded: https://www.youtube.com/watch?v=qPXZWwnuWhU&feature=youtu.be
This odd behaviour never occurs in iOS10, only in iOS 11, on this particular device and orientation.
My constraints are blue and 0 warnings/errors, I played around with them to see if the safe area was related to this problem. The problem is gone only if I set the leading/trailing constraints of the UITableView to line up with the Safe Area. If they are lined up with the Superview, this error occurs.
Anyone have an idea what could be causing this?

Related

Sluggish response from custom slider

I am trying to create a custom slider that uses a UIButton as the thumb. Since I can't seem to find a way to use a UIView as the thumb in a slider, I decided to build a custom one. However, when I run it, the thumb is sluggish to respond on the initial drag. The following is the code so far. The gif below is running slower than actual speed but illustrates the point.
import Foundation
import UIKit
class CustomSlider: UIView, UIGestureRecognizerDelegate {
let buttonView = UIButton()
var minimumPosition = CGPoint()
var maximumPosition = CGPoint()
var originalCenter = CGPoint()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() {
// add a pan recognizer
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(recognizer:)))
recognizer.delegate = self
addGestureRecognizer(recognizer)
backgroundColor = UIColor.green
buttonView.frame = CGRect(x: 0, y: 0, width: bounds.height, height: bounds.height)
buttonView.backgroundColor = UIColor.purple
self.addSubview(buttonView)
minimumPosition = CGPoint(x:buttonView.frame.width / 2, y: 0)
maximumPosition = CGPoint(x: bounds.width - buttonView.frame.width / 2, y: 0)
}
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
if recognizer.state == .began {
originalCenter = buttonView.center
}
if recognizer.state == .changed {
let translation = recognizer.translation(in: self)
let newX = originalCenter.x + translation.x
buttonView.center = CGPoint(x: min(maximumPosition.x, max(minimumPosition.x, newX)), y: buttonView.frame.midY)
}
}
}
To make it look smoother, you should reset the translation of the pan and just add it continuously to the center offset:
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
if recognizer.state == .began {
// originalCenter = buttonView.center
}
if recognizer.state == .changed {
originalCenter = buttonView.center
let translation = recognizer.translation(in: self)
let newX = originalCenter.x + translation.x
buttonView.center = CGPoint(x: min(maximumPosition.x, max(minimumPosition.x, newX)), y: buttonView.frame.midY)
recognizer.setTranslation(.zero, in: self)
}
}
Also, for making a custom control, you can check out this tutorial:
https://www.sitepoint.com/wicked-ios-range-slider-part-one/
It might be old and in objective-c, but it gives the general idea of overriding UIControl, handling touches and creating a custom control - which you can also extend with #IBDesignable and #IBInspectable.
Hope it helps.
Finally figured it out. It only happens in the iPhone XR simulator. Tried the iPhone XS, among others, and they were smooth as glass.

UIButton is not clickable after first click

I am trying to bring a subview from bottom when button is clicked. But only for the first time the button is clickable. For second click after animation button is not clickable.
Here is the code.
class AnimateView: UIView {
var button: UIButton!
var menuView: UIView!
var mainView: UIView!
override init(frame: CGRect) {
super.init(frame: frame)
mainView = UIView(frame: CGRect(x: 0, y: 0, width:
self.frame.size.width, height: self.frame.size.height))
mainView.clipsToBounds = true
mainView.backgroundColor = .clear
mainView.isUserInteractionEnabled = true
mainView.isExclusiveTouch = true
self.addSubview(mainView)
let theRect = CGRect(x: self.frame.size.width / 2 - 44 / 2, y: 0,
width: 44, height: 44)
button = UIButton(frame: theRect)
check.layer.cornerRadius = btnView.frame.size.height / 2
mainView.addSubview(self.check)
mainView.bringSubview(toFront: check)
check.addTarget(self, action: #selector(buttonClick(_:)),
for:.touchUpInside)
let newRect = CGRect(x: 0, y: check.frame.height, width:
self.frame.size.width, height: self.mainView.frame.size.height)
menuView = UIView(frame: newRect)
menuView.backgroundColor = .blue
mainView.addSubview(menuView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func buttonClick(_ sender: UIButton) {
toggleMenu()
}
func toggleMenu() {
if check.transform == CGAffineTransform.identity {
UIView.animate(withDuration: 1, animations: {
self.check.transform = CGAffineTransform(scaleX: 12, y: 12)
self.mainView.transform = CGAffineTransform(translationX: 0, y: -120)
self.check.transform = CGAffineTransform(rotationAngle: self.radians(180)) // 180 is 3.14 radian
self.setNeedsLayout()
}) { (true) in
UIView.animate(withDuration: 0.5, animations: {
})
}
} else {
UIView.animate(withDuration: 1, animations: {
// .identity sets to original position
//self.btnView.transform = .identity
self.mainView.transform = .identity
self.button.transform = .identity
})
}
}
}
This class is called from another UIView class like this
class MainViewClass: UIView {
var animateView: AnimateView!
override init(frame: CGRect) {
animateView = AnimateView(frame: CGRect(x: 0, y: self.frame.size.height - 44 - 44 - 20, width: self.frame.size.width, height: 122+44))
//animateView.clipsToBounds = true
self.addSubview(animateView)
self.bringSubview(toFront: animateView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
When the screen first appears:
Initially the button is clickable and when it is clicked it moves up along with subview.
Now the button is not clickable. When I saw the frame of button it is same as when screen initially appears with button touching the screen bottom even though it moves up.
What I am intending is to show the screen with animation from bottom when button is clicked and move to default position for second time click.
But if I place the button as subview inside self, not as subview for mainView, then the button is clickable but it remains in the same position even after view is animated and moved up.
self.addSubview(button)
So, I finally got the solution of what I was intending to achieve before and after animation.
The reason for the UIButton not receiving any touch event after the animation is that after animation, the UIButton moves upward as it is subview of mainView. Due to this, it is out of bound of its superView and does not respond to any click event. Although I moved button along with its superView which is mainView, but button did not respond to touch event.
Then, I tested the whether the button bound is within mainView using hitTest:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pointForTargetView: CGPoint = button.convert(point, from: mainView)
if button.bounds.contains(pointForTargetView) {
print("Button pressed")
return button.hitTest(pointForTargetView, with:event)
}
return super.hitTest(point, with: event)
}
This if condition:
button.bounds.contains(pointForTargetView)
Returns false when button is pressed after animation.
So, I need to capture touch event on subview which is out the its superview frame.
Using hitTest() solves my problem.
Here's the link which helped me. Capturing touches on a subview outside the frame of its superview using hitTest:withEvent:
Swift 3
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if clipsToBounds || isHidden || alpha == 0 {
return nil
}
for subview in subviews.reversed() {
let subPoint = subview.convert(point, from: self)
if let result = subview.hitTest(subPoint, with: event) {
return result
}
}
return nil
}
The screen shots:
When screen first appears, button is at bottom of screen, subview is hidden below the button.
When the button is clicked, mainView animates upward and button moves upward as it is subview of mainView.
When button is clicked again, mainView goes to previous position so does the button.

Retrieving the right position of a scaled UIView

I'm having trouble retrieving the current position of a view, absolutely in the iPhone screen, when the view is scaled.
Here is a minimal example of what I've done.
I have a single elliptical view on screen. When I tap, it grows and start to shiver to show that it is selected. Then I can drag the view all over the screen.
Here is the code:
ViewController
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// The View
let toDrag = MyView(frame: CGRect(x: 0, y: 0, width: 50, height: 40))
toDrag.center = CGPoint(x:100, y:100)
// The gesture recognizer
let panGestRec = UIPanGestureRecognizer(target: self, action:#selector(self.panView(sender:)))
toDrag.addGestureRecognizer(panGestRec)
self.view.addSubview(toDrag)
}
func panView(sender: UIPanGestureRecognizer) {
guard let senderView = sender.view else { return }
if sender.state == .recognized { print("Pan Recognized", terminator:"")}
if sender.state == .began { print("Pan began", terminator: "") }
if sender.state == .changed { print("Pan changed", terminator: "") }
if sender.state == .ended { print("Pan ended", terminator: "") }
let point = senderView.convert(senderView.center, to: nil)
print (" point \(point)")
let translation = sender.translation(in: self.view)
senderView.center = CGPoint(x: senderView.center.x + translation.x, y: senderView.center.y + translation.y)
sender.setTranslation(CGPoint.zero, in: self.view)
}
}
MyView
import UIKit
class MyView: UIView {
var isAnimated: Bool = false
override init(frame: CGRect) {
super.init(frame: frame) // calls designated initializer
self.isOpaque = false
self.backgroundColor = UIColor.clear
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.isOpaque = false
self.backgroundColor = UIColor.clear
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
isAnimated = true
rotate1()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
isAnimated = false
endRotation()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
isAnimated = false
endRotation()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {return}
context.addEllipse(in: rect)
context.setLineWidth(1.0)
context.setStrokeColor(UIColor.black.cgColor)
context.setFillColor(UIColor.red.cgColor)
context.drawPath(using: .fillStroke)
}
func rotate1() {
guard isAnimated else { return }
UIView.animate(withDuration: 0.05, delay:0.0, options: [], animations: {
self.transform = CGAffineTransform.init(rotationAngle:.pi/16).scaledBy(x: 1.5, y: 1.5)
}, completion: { _ in
self.rotate2()
})
}
func rotate2() {
guard isAnimated else { return }
UIView.animate(withDuration: 0.05, delay:0.0, options: [], animations: {
self.transform = CGAffineTransform.init(rotationAngle:.pi/(-16)).scaledBy(x: 1.5, y: 1.5)
}, completion: { _ in
self.rotate1()
})
}
func endRotation () {
// End existing animation
UIView.animate(withDuration: 0.5, delay:0.0, options: .beginFromCurrentState, animations: {
self.transform = CGAffineTransform.init(rotationAngle:0 ).scaledBy(x: 1, y: 1)
}, completion: nil)
}
}
With this version, here are some displays of the position
Pan began point (233.749182687299, 195.746572421573)
Pan changed point (175.265022520054, 181.332358181369)
Pan changed point (177.265022520054, 184.332358181369)
Pan changed point (177.265022520054, 184.332358181369)
Pan changed point (178.265022520054, 185.332358181369)
Pan changed point (179.265022520054, 186.332358181369)
Pan changed point (180.265022520054, 187.332358181369)
Pan changed point (180.265022520054, 188.332358181369)
Pan changed point (181.265022520054, 188.332358181369)
Pan RecognizedPan ended point (181.265022520054, 189.332358181369)
You can see that the first position (began point) isn't correct: the view was animated at that moment. The other positions are OK, when dragging, the view don't animate.
If I comment the code in rotate1 and rotate2, all the positions are correct, so I assume that the sizing and eventually the rotation of the view are interfering in the result. My question is: how can I retrieve the correct position of the view, when it is scaled? Obviously, the line
senderView.convert(senderView.center, to: nil)
didn't make what I thought : converting the coordinate to the fixed size screen coordinates?
I tried changing the animation from View animation to Core Animation (layer), and the result was the same.
So I tried to imagine a formula, with size, bounds and frame values, that could result in the correct position, but I did not succeed...
The only think that worked, was to stop immediately the animation with a transform, just before trying to retrieve the center, like that in panView
func panView(sender: UIPanGestureRecognizer) {
guard let senderView = sender.view else { return }
// Stop the transformation now!
senderView.transform = CGAffineTransform.init(rotationAngle:0 ).scaledBy(x: 1, y: 1)
if sender.state == .recognized { print("Pan Recognized", terminator:"")}
...
It isn't the solution I wanted, but I'll go with this for now...
In fact, I had another problem with view convert (Retrieve the right position of a subView with auto-layout)
and my problem was the same, so the solution is here...
I didn't use the method correctly. If I change using beninho85's solution (and even more cleaner cosyn's method to use just one view), it works! I can have the right coordinates even if the view is animated.
I just had to replace, in ViewController :
let point = senderView.convert(senderView.center, to: nil)
with
let point = senderView.convert(CGPoint(x:senderView.bounds.midX, y:senderView.bounds.midY), to: nil)

Drag and drop - UIGestureRecognizer - View added not where I want to

I have this 2 tableViews (All Services available and service offered) in a splitViewController. The idea is that then the cell is moved across the center of the screen, it moves to the service offered table view.
Everything works except then the touch `.began, the cell is being added to the top of the tableView covering the navBar.
On .changed it follows my finger
I would like the view/cell to be added where the touch began, on top of the "ServiceCell" I am touching. I tried adding it to the splitViewController.view but I think there is something wrong with my logic.
Here the code:
func didLongPressCell (gr: UILongPressGestureRecognizer) {
let serviceCell:ServiceCell = gr.view as! ServiceCell
switch gr.state {
case .began:
let touchOffsetInCell = gr.location(in: gr.view)
let dragEvent = DragganbleServiceCell()
mDraggableServiceCell = dragEvent.makeWithServiceCell(serviceCell: serviceCell, offset: self.tableView.contentOffset, touchOffset: touchOffsetInCell)
self.splitViewController?.view.addSubview(mDraggableServiceCell!)
case .changed:
let cp:CGPoint = gr.location(in: self.view)
let newOrigin = CGPoint(x: (cp.x), y: (cp.y) - (mDraggableServiceCell?.touchOffset?.y)!)
UIView.animate(withDuration: 0.1, animations: {
self.mDraggableServiceCell?.frame = CGRect(origin: newOrigin, size: (self.mDraggableServiceCell?.frame.size)!)
})
case .ended:
let detailViewNC = self.splitViewController?.viewControllers[1] as! UINavigationController
let detailView = detailViewNC.topViewController as! ServiceOfferedTableViewController
if (mDraggableServiceCell?.frame.intersects(detailView.tableView.frame))! {
onDragEnded(serviceCell:serviceCell)
}else{
mDraggableServiceCell?.removeFromSuperview()
}
default:
print("Any other action?")
}
}
In DraggableServiceCell:
class DragganbleServiceCell: ServiceCell {
var originalPosition:CGPoint?
var touchOffset:CGPoint?
func makeWithServiceCell(serviceCell:ServiceCell, offset:CGPoint, touchOffset:CGPoint)->DragganbleServiceCell{
let newFrame = CGRect(x: serviceCell.frame.origin.x, y: serviceCell.frame.origin.y, width: serviceCell.frame.size.width, height: serviceCell.frame.size.height)
let dragCell = DragganbleServiceCell(frame: newFrame)
dragCell.touchOffset = touchOffset
dragCell.service = serviceCell.service
dragCell.serviceName.text = serviceCell.service?.service_description
return dragCell
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.containingView.backgroundColor = UIColor.rgb(230, green: 32, blue: 31)
self.serviceName.textColor = .white
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Your problem is in makeWithServiceCell(serviceCell:ServiceCell, offset:CGPoint, touchOffset:CGPoint) -> DragganbleServiceCell. The frame of your serviceCell is relative to the tableView that contains it instead of UISplitViewController's view and you are never using the offset or touchOffset values to correct that. So try adjusting the frame in the same way you are doing it in the .changed event.
You might even find that the convert(_:to:) method on UIView might be useful to do CGPoint transformations between two views. See more details here.
I would suggest adding the gesture to self.splitViewController?.view instead of self.viewif you are not doing and changing this part of the code.
case .changed:
let cp:CGPoint = gr.location(in: self.view)
let newOrigin = CGPoint(x: (cp.x), y: (cp.y) - (mDraggableServiceCell?.touchOffset?.y)!)
UIView.animate(withDuration: 0.1, animations: {
self.mDraggableServiceCell?.frame = CGRect(origin: newOrigin, size: (self.mDraggableServiceCell?.frame.size)!)
})
to this
case .changed:
let cp:CGPoint = gr.location(in: self.splitViewController?.view)
UIView.animate(withDuration: 0.1, animations: {
self.mDraggableServiceCell?.center = cp
})
try once.

Drag behaviour wrong after UIView transformation

I have a custom DraggableView that subclasses UIImageView. I take a photo with the camera, add the resulting UIImage to a DraggableView and then I can happily drag it around the screen, as intended.
Now, if the original photo was taken in landscape, I rotate it using:
if (image?.size.width > image?.size.height)
{
self.transform = CGAffineTransformMakeRotation(CGFloat(M_PI_2))
}
When I apply this transformation, the drag behaviour still works, but the directions are all wrong - if I drag left, the image moves up, not left. If I drag up, the image moves right, not up.
How do I fix this? I guess it is something to do with the UIPanGestureRecognizer being bound to the non-transformed view?
Edit: Current UIPanGestureRecognizer handler:
func onPhotoDrag(recognizer: UIPanGestureRecognizer?)
{
let translation = recognizer!.translationInView(recognizer?.view)
recognizer!.view!.center = CGPointMake(recognizer!.view!.center.x
+ translation.x, recognizer!.view!.center.y + translation.y);
recognizer?.setTranslation(CGPointZero, inView: recognizer?.view)
if (recognizer!.state == UIGestureRecognizerState.Ended)
{
let velocity = recognizer!.velocityInView(recognizer?.view)
let magnitude = sqrt((velocity.x * velocity.x)
+ (velocity.y * velocity.y))
let slideMult = magnitude / 300;
let slideFactor = 0.1 * slideMult;
let finalPoint = CGPointMake(recognizer!.view!.center.x
+ (velocity.x * slideFactor),
recognizer!.view!.center.y + (velocity.y * slideFactor));
// Animate the drag, and allow the drag delegate to do its work
DraggableView.animateWithDuration(Double(slideFactor),
delay: 0, options: UIViewAnimationOptions.CurveEaseOut,
animations: { recognizer?.view?.center = finalPoint },
completion: {(_) -> Void in self.dragDelegate?.onDragEnd(self)})
} // if: gesture ended
}
Update:
Thanks for posting your code. I pasted your code into my DraggableImageView and reproduced your problem. Although my version was handling the rotated view (without the animation), yours was going sideways.
The difference is that my code asks for the translationInView in the superview of the draggable view. You need to ask for the translationInView and velocityInView in the superview of your draggable view.
Change this line:
let translation = recognizer!.translationInView(recognizer?.view)
to:
let translation = recognizer!.translationInView(recognizer?.view?.superview)
and change this:
let velocity = recognizer!.velocityInView(recognizer?.view)
to:
let velocity = recognizer!.velocityInView(recognizer?.view?.superview)
and all will be happy.
Previous Answer:
Try this version:
class DraggableImageView: UIImageView {
override var image: UIImage? {
didSet {
if (image?.size.width > image?.size.height)
{
self.transform = CGAffineTransformMakeRotation(CGFloat(M_PI_2))
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
self.setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.setup()
}
func setup() {
self.userInteractionEnabled = true
let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.addTarget(self, action: #selector(draggedView(_:)))
self.addGestureRecognizer(panGestureRecognizer)
}
func moveByDeltaX(deltaX: CGFloat, deltaY: CGFloat) {
self.center.x += deltaX
self.center.y += deltaY
}
func draggedView(sender:UIPanGestureRecognizer) {
if let dragView = sender.view as? DraggableImageView, superview = dragView.superview {
superview.bringSubviewToFront(dragView)
let translation = sender.translationInView(superview)
sender.setTranslation(CGPointZero, inView: superview)
dragView.moveByDeltaX(translation.x, deltaY: translation.y)
}
}
}
Use example:
override func viewDidLoad() {
super.viewDidLoad()
let dragView = DraggableImageView(frame: CGRect(x: 50, y: 50, width: 96, height: 128))
dragView.image = UIImage(named: "landscapeImage.png")
self.view.addSubview(dragView)
}

Resources