How to mimic Apple Map's translating table view? - ios

I am making an app that returns venues (like restaurants) in a fashion similar to apple maps, i.e. a table view that is initially hidden mostly off screen and then moves up above other content when the table view's contents are to be displayed.
This is what I've gotten so far:
You'll notice that when pulling the table view back down to reveal the map behind it, the table view does not move down in one fluid motion. In fact, when the table view reaches its "top," you'll notice that the scroll bar on the right side is visible but the view isn't moving. This is because, while recording, I'm dragging downward but the view takes a few moments to actually begin to translate downward. I'll explain why it has this sort of delay, but first compare this behavior to that of the Apple maps table view:
Notice the fluidity in the Apple maps table view's transition from translating the view itself to scrolling the scroll view. A translation upward of the view converts into a scrolling downward of the scroll view (and vice versa) when the view's minY hits a certain point. This is all done throughout one upward swipe by the user.
Now I'll explain what I did to create my translating scroll view. The scroll view itself is a UITableViewController embedded in a container view on the map's view controller.
The view that holds the container view has a pan gesture recognizer. When the pan gesture recognizer's state is UIGestureRecognizerState.ended, i.e. the drag has ended, it checks the container view's minY in relation to a predetermined Y coordinate. If the minY is less than the predetermined Y coordinate, it will animate the container view to snap its minY to that Y coordinate. The case is the same if the minY is slightly greater than the predetermined Y coordinate, in this instance the container view will animate upward so its minY snaps to this predetermined Y coordinate.
Once the view has snapped into this position, the embedded UITableViewController's ".isScrollEnabled" property, which is initially set to false, is now set to true. When the embedded table view is now scroll enabled, a drag executed by the user will scroll the table view as opposed to translating the container view. To undo this, I've made it so that when the table view has reached the top of its scroll, its ".isScrollEnabled" property is set back to false. This means that any dragging motion by the user will translate the view as opposed to scrolling the table view. This does not create the fluidity in transition between scrolling and translating the way Apple maps does. The user would actually have to execute multiple drags on the area in order to transition from scrolling to translating; Apple maps is able to do this in one drag.
Here is the code I used within the container view's parent View controller to:
Snap container view into place
Change .isScrollEnabled property of embedded table view controller to true
#objc func handlePan(sender: UIPanGestureRecognizer) {
let resultView = self.resultView
let translation = sender.translation(in: view)
switch sender.state {
case .began, .changed:
resultView?.center = CGPoint(x: (resultView?.center.x)!, y: (resultView?.center.y)! + translation.y)
sender.setTranslation(CGPoint.zero, in: view)
if (resultView?.frame.minY)! <= self.view.frame.height - self.searchView.frame.height - self.view.safeAreaInsets.bottom && self.resultView.frame.height >= (self.searchView.frame.height + self.view.safeAreaInsets.bottom) {
resultView?.frame.size.height -= translation.y
}
case .ended:
if (resultView?.frame.minY)! > self.view.frame.height * 0.40 {
self.resultTableViewController?.tableView.isScrollEnabled = false
} else {
self.resultTableViewController?.tableView.isScrollEnabled = true
}
if (resultView?.frame.minY)! <= view.frame.height * 0.40 {
let difference = (resultView?.frame.minY)! - (view.frame.height * 0.05)
UIView.animate(withDuration: 0.25) {
resultView?.center = CGPoint(x: (resultView?.center.x)!, y: (resultView?.center.y)! - difference)
resultView?.frame.size.height += difference
self.view.layoutIfNeeded()
}
dismissed = false
} else if (resultView?.frame.minY)! >= view.frame.height * 0.75 {
let difference = (view.frame.height - dismissResultButton.frame.height - view.safeAreaInsets.bottom) - (resultView?.frame.minY)!
UIView.animate(withDuration: 0.25, animations: {
resultView?.center = CGPoint(x: (resultView?.center.x)!, y: (resultView?.center.y)! + difference)
self.resultView.frame.size.height = self.searchView.frame.size.height + self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
})
dismissed = true
} else if (resultView?.frame.minY)! >= view.frame.height * 0.40 {
let difference = mapView.frame.height - (resultView?.frame.minY)!
UIView.animate(withDuration: 0.25) {
resultView?.center = CGPoint(x: (resultView?.center.x)!, y: (resultView?.center.y)! + difference)
self.resultView.frame.size.height = self.searchView.frame.size.height + self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
}
dismissed = false
}
sender.setTranslation(CGPoint.zero, in: view)
default:
break
}
}
And now, within the UITableViewController:
Ensures that the scroll view only bounces when at the bottom of the scroll (it should lock and then convert to a container translation when reaching the top)
Change .isScrollEnabled back to false and allow container view translation on drag
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.bounces = (scrollView.contentOffset.y > 0)
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
let translation = scrollView.panGestureRecognizer.translation(in: scrollView.superview!)
if translation.y > 0 {
if scrollView.contentOffset.y == 0 {
tableView.isScrollEnabled = false
}
}
}
This is the best I could come up with to try and replicate this behavior by the Apple maps table view. Any help/suggestions would be appreciated.
P.S. heres the github to the project if you'd like to break it down more: https://github.com/ChopinDavid/What2Do

Related

Panning UIViewController makes things pinned to safe area jump to the superview

I'm using this code to make a UIViewController pannable take from this so post.
class ViewControllerPannable: UIViewController {
var panGestureRecognizer: UIPanGestureRecognizer?
var originalPosition: CGPoint?
var currentPositionTouched: CGPoint?
override func viewDidLoad() {
super.viewDidLoad()
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureAction(_:)))
view.addGestureRecognizer(panGestureRecognizer!)
}
func panGestureAction(_ panGesture: UIPanGestureRecognizer) {
let translation = panGesture.translation(in: view)
if panGesture.state == .began {
originalPosition = view.center
currentPositionTouched = panGesture.location(in: view)
} else if panGesture.state == .changed {
view.frame.origin = CGPoint(
x: translation.x,
y: translation.y
)
} else if panGesture.state == .ended {
let velocity = panGesture.velocity(in: view)
if velocity.y >= 1500 {
UIView.animate(withDuration: 0.2
, animations: {
self.view.frame.origin = CGPoint(
x: self.view.frame.origin.x,
y: self.view.frame.size.height
)
}, completion: { (isCompleted) in
if isCompleted {
self.dismiss(animated: false, completion: nil)
}
})
} else {
UIView.animate(withDuration: 0.2, animations: {
self.view.center = self.originalPosition!
})
}
}
}
}
This works great on older phones that don't have a notch. But if the phone has a notch, once you start panning, any views pinned to the safe area jump to the superview. I think the issue is in the
view.frame.origin = CGPoint(
x: translation.x,
y: translation.y
)
But I'm not sure how to make anything that was pinned to the safe area stay that way when panning.
You want to avoid layoutSubviews after you drag the view outside the superview's frame. As long as you are not calling setNeedsLayout manually, you can achieve this by ensuring that last frame.size change happens before crossing outside. Just make enough variables to precisely control for this situation.
I encountered this issue with a custom action-sheet-like presentation. When I pulled the view down to dismiss, there was no problem. But when I pulled the view up to stretch and then down, the safeAreaInsets jumped from 0 to non-zero suddenly.
The reason was because my view stretched upwards but not downwards. Pull up was frame.origin.y change and a frame.size.height change. Downwards was only a frame.origin.y change. However, UIPanGestureRecognizer does not have infinite granularity, and so the jump from negative to positive translation caused origin.y to change at the same time as frame.size.height in the downward direction. This triggered layoutSubviews while the view was partially outside the superview, which meant it ran layout code that suddenly acknowledged a safeAreaInsets change.
My solution is essentially:
if translation < 0 && oldTranslation > 0 || translation > 0 && oldTranslation < 0 {
view.frame = originalFrame
} else {
view.frame = calculate(translation)
}
This forces the view to be a static size before it crosses the superview's boundary, ensuring that layoutSubviews is called with the correct safeAreaInsets, but as the position changes, the safeArea does not.
Your situation may be a little different if you have something else triggering layoutSubviews. You might have to hunt that down.
Another thing I notice is that you're moving the UIViewController's view when it might have a parent, which could have other implications or edge cases because safeArea is inherited from the UIViewController hierarchy rather than solely superview.

Changing y position of view removes tap events

I set a UICollectionView y position outside the screen (set it aligned to the bottom safe area, and offset -154, so it is off screen).
When the app starts, I use a button to move the entire root view up
self.view.center.y -= 154
Both the root view and my UIView appear in expected position, but the UICollectionView can not be tapped. The new zone does not recognize the UIEvent.
I added a gesture to the UICollectionView but it doesn't work. Only the root view zone responds to the UIEvent.
#IBAction func filmRollButton(_ sender: UIButton) {
var toUp = true
if self.toUp == true {
self.view.center.y -= 154
} else {
self.view.center.y += 154
}
self.toUp = !self.toUp
}
// tap - UICollection View appear(y position change)
// tap again - UICollection view disappear
Expected:
Use some method to enable the UICollectionView tap event. (extend root view zone? because only root view zone can be tapped.)
Other simple draw effect like setting the UICollectionView height to 0, then tap button and set the height to 154.
Try using layoutIfNeeded() , to layout subviews right away. Might be pending
#IBAction func filmRollButton(_ sender: UIButton) {
var toUp = true
if self.toUp == true {
self.view.center.y -= 154
self.view.layoutIfNeeded()
} else {
self.view.layoutIfNeeded()
self.view.center.y += 154
}
self.toUp = !self.toUp
}
// tap - UICollection View appear(y position change)
// tap again - UICollection view disappear

How to make scroll view stop moving when dragging on it and resume moving immediately for the already touching finger?

How can I make the scroll view stop moving when dragging on it, after that, how can I make it start scrolling immediately when finger already touching the scroll view?
self.scrollView.notMovingWhileDragging()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.scrollView.resumeMovingForTheAlreadyTouchingFinger()
}
For the above two functions, I set isScrollEnabled to false then true, but the scroll view not move if finger already touching it when above callback is called.
The result I want is like the iOS Maps app, when the sheet is on top, and the scroll view is been dragged a little distance from top. Now I start dragging down the scroll view, scroll view move down, when scroll view is been scroll to top, then keep dragging down, the sheet down(scroll view stop moving when dragging), then dragging up, the sheet view up, and when the sheet view is on top, keep dragging up, the scroll view start scrolling again (scroll view resume moving when finger already on it).
It's not possible. The isScrollEnabled property of a UIScrollView simply updates the isEnabled property of the UIScrollView's panGesture. UIPanGesture has a defined lifecycle that begins with touchesBegan as a touch down. and its state moves to .began, so enabling it while a touch has already begun will not cause it to fire.
In your original question you asked how to allow scrolling after handling the positioning of a sheet-style interface a la Apple Maps. This can be done by adding a target your scroll view's panGesture. For example, you can add a target like so tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))); then in your action you can say something like:
#objc func handlePan(_ sender: UIPanGestureRecognizer) {
let velocity = sender.velocity(in: view)
let translation = sender.translation(in: view)
switch sender.state {
case .began: break
case .changed:
// shouldMoveSheet can check to see if the sheet should move or scroll view should scroll based on content offset, pan translation, etc.
if shouldMoveSheet(for: translation) {
bottomConstraint.constant = newConstant
// while moving sheet set the content offset to the top so the scroll view does not scroll
tableView.setContentOffset(CGPoint(x: 0.0, y: tableView.adjustedContentInset.top), animated: false)
sender.setTranslation(.zero, in: view)
}
case .ended:
// Depending on if the sheet has been moved when the gesture ends move the sheet to the up or down position
if velocity.y < 0 && tableView.contentOffset.y <= tableView.adjustedContentInset.top {
bottomConstraint.constant = constantForSheetUp
} else if tableView.contentOffset.y <= tableView.adjustedContentInset.top {
bottomConstraint.constant = constantForSheetDown
}
UIView.animate(duration: 0.3) {
view.layoutIfNeeded()
}
case .failed, .cancelled, .possible:
break
}
}
Which will enable a pan up to some threshold then scrolling:

How to limit the area in which an object can be moved to the boundaries of another view with pan gestures?

I want to restrict the area a UITextView can be moved to only the frame of another view. I tried to accomplish this with the following code, however, it will not properly restrict where the user can drag the UITextView:
#IBOutlet weak var canvas: UIView!
#IBAction func handleDragOfCaption(_ sender: UIPanGestureRecognizer) {
if sender.state == .began || sender.state == .changed {
guard let senderView = sender.view else {return}
let translation = sender.translation(in: sender.view)
let x = senderView.center.x + translation.x
let y = senderView.center.y + translation.y
let point = CGPoint(x: x, y: y)
if canvas.frame.contains(point) {
senderView.center = point
sender.setTranslation(CGPoint.zero, in: senderView)
}
}
}
See the following image for a visual representation of what I am trying to accomplish. I only want the user to be able to drag the UITextView within the boundaries of the UIView (which is the white square underneath the UITextView). Any help would be appreciated.
Here is another approach, using constraints...
Give your "draggable view" (your text view, in this case) a Width constraint and disable scrolling (that will allow it to auto-size its height).
Constrain the "draggable view" to Zero on all four sides - then edit those constraints to be >= 0. This will prevent the view from being dragged outside its parent view.
Also constrain the "draggable view" to Center Horizontally and Vertically - then edit those constraints to have Priority = 750. By setting the center constraints to a lower priority than the edge constraints, the centers will only take effect if the edges are fully inside the parent view.
Connect the center constraints to IBOutlets in your view controller, and add vars for "current" constant values for the center constraints.
Now add a Pan Gesture Recognizer to the "draggable view".
When you start the pan, save the center constraint constants to the "current" vars.
When you pan, get the translation X and Y (the distance moved from the start of the pan), and update the center constants based on the difference between the start and new values. This will move the "draggable view" to its new position.
When you finish the pan, update the center constants based on the final position of the "draggable view".
#IBAction func handleDragOfCaption(_ sender: UIPanGestureRecognizer) {
guard let senderView = sender.view else { return }
guard let parentView = senderView.superview else { return }
// get the current pan movement - relative to the view (the text view, in this case)
let translation = sender.translation(in: canvas)
switch sender.state {
case .began:
// save the current Center X and Y constants
currentCenterXConstant = textViewCenterXConstraint.constant
currentCenterYConstant = textViewCenterYConstraint.constant
break
case .changed:
// update the Center X and Y constants
textViewCenterXConstraint.constant = currentCenterXConstant + translation.x
textViewCenterYConstraint.constant = currentCenterYConstant + translation.y
break
case .ended, .cancelled:
// update the Center X and Y constants based on the final position of the dragged view
textViewCenterXConstraint.constant = senderView.center.x - parentView.frame.size.width / 2.0
textViewCenterYConstraint.constant = senderView.center.y - parentView.frame.size.height / 2.0
break
default:
break
}
}
To help with setting up the constraints correctly, I've posted an example project here: https://github.com/DonMag/LimitDrag

How do you implement a UIPanGestureRecognizer to translate and dismiss a UITableViewController with swift?

I am attempting to use UIPanGestureRecognizer to translate and dismiss a UITableViewController. I want the gesture to trigger translation of the TableView only when it has reached the bottom of its scroll view and dismiss the TableView when it has been translated 1/3 the height of the screen. I've tried to add the GestureRecognizer when the TableView has reached the bottom of its scroll view, but the application ends up adding the gesture recognizer and disabling the freedom to scroll back up in the TableView.
I can supply code upon request, but I should be able to follow general solutions you may have.
Thanks in advance.
Few extra things to note:
I've added the gesture recognizer to the table view
I want to create a similar effect to a particular view in Facebook's iPhone application. In their app, whenever you select a photo from a photo album, it presents a TableView that allows you to translate and dismiss it whenever you reach the any of the edges in its scrollview.
Here's the code I currently have:
override func scrollViewDidScroll(scrollView: UIScrollView) {
// MARK: - Scrollview dismiss from bottom
let scrollViewHeight = scrollView.frame.height
let scrollContentSizeHeight = scrollView.contentSize.height
let scrollOffset = scrollView.contentOffset.y
// Detects if scroll view is at bottom of table view
if scrollOffset + scrollViewHeight >= scrollContentSizeHeight {
println("Reached bottom of table view")
self.panGesture = UIPanGestureRecognizer(target: self, action: "slideViewFromBottom:")
self.imageTableView.addGestureRecognizer(panGesture)
}
}
func slideViewFromBottom(recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translationInView(imageTableView).y
let velocity = recognizer.velocityInView(self.imageTableView)
var centerOfImageTableView = self.imageTableView.center.y
switch recognizer.state {
case .Began:
// Touches begin
println("Touches began")
case .Changed:
// Limits view translation to only pan up
if velocity.y < 0.0 {
centerOfImageTableView = self.screenbounds.height/2 + translation
}
case .Ended:
// Determines length of translation to be animated
let moveToTop = screenbounds.height + translation
// Animates view depending on view location at end of gesture
if translation <= -UIScreen.mainScreen().bounds.height/2 {
UIView.animateWithDuration(0.2) {
self.imageTableView.transform = CGAffineTransformMakeTranslation(0.0, -moveToTop)
return
}
delay(0.3) {
self.presentingViewController?.dismissViewControllerAnimated(false, completion: nil)
}
}
else {
UIView.animateWithDuration(0.3) {
self.imageTableView.transform = CGAffineTransformMakeTranslation(0.0, -translation)
return
}
recognizer.enabled = false
}
default:
println("Default executed")
}
}

Resources