I am using a custom path animation on UIImageView items for a Swift 3 project. The code outline is as follows:
// parentView and other parameters are configured externally
let imageView = UIImageView(image: image)
imageView.isUserInteractionEnabled = true
let gr = UITapGestureRecognizer(target: self, action: #selector(onTap(gesture:)))
parentView.addGestureRecognizer(gr)
parentView.addSubview(imageView)
// Then I set up animation, including:
let animation = CAKeyframeAnimation(keyPath: "position")
// .... eventually ....
imageView.layer.add(animation, forKey: nil)
The onTap method is declared in a standard way:
func onTap(gesture:UITapGestureRecognizer) {
print("ImageView frame is \(self.imageView.layer.visibleRect)")
print("Gesture occurred at \(gesture.location(in: FloatingImageHandler.parentView))")
}
The problem is that each time I call addGestureRecognizer, the previous gesture recognizer gets overwritten, so any detected tap always points to the LAST added image, and the location is not detected accurately (so if someone tapped anywhere on the parentView, it would still trigger the onTap method).
How can I detect a tap accurately on per-imageView basis? I cannot use UIView.animate or other methods due to a custom path animation requirement, and I also cannot create an overlay transparent UIView to cover the parent view as I need these "floaters" to not swallow the events.
It is not very clear what are you trying to achieve, but i think you should add gesture recognizer to an imageView and not to a parentView.
So this:
parentView.addGestureRecognizer(gr)
Should be replaced by this:
imageView.addGestureRecognizer(gr)
And in your onTap function you probably should do something like this:
print("ImageView frame is \(gesture.view.layer.visibleRect)")
print("Gesture occurred at \(gesture.location(in: gesture.view))")
I think you can check the tap location that belongs imageView or not on the onTap function.
Like this:
func ontap(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: parentView)
if imageView.layer.frame.contains(point) {
print("ImageView frame is \(self.imageView.layer.visibleRect)")
print("Gesture occurred at \(point)")
}
}
As the layers don't update their frame/position etc, I needed to add the following in the image view subclass I wrote (FloatingImageView):
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
let prespt = self.superview!.layer.convert(suppt, to: pres)
return super.hitTest(prespt, with: event)
}
I also moved the gesture recognizer to the parent view so there was only one GR at any time, and created a unique tag for each of the subviews being added. The handler looks like the following:
func onTap(gesture:UITapGestureRecognizer) {
let p = gesture.location(in: gesture.view)
let v = gesture.view?.hitTest(p, with: nil)
if let v = v as? FloatingImageView {
print("The tapped view was \(v.tag)")
}
}
where FloatingImageView is the UIImageView subclass.
This method was described in an iOS 10 book (as well as in WWDC), and works for iOS 9 as well. I am still evaluating UIViewPropertyAnimator based tap detection, so if you can give me an example of how to use UIViewPropertyAnimator to do the above, I will mark your answer as the correct one.
Related
I'm trying to work out the best method for finding the exact subview (if any) at a given CGPoint in a UIView.
Part of my scene/ViewController is a custom UIView subclass (designed in a XIB), which presents a UIStackView of subviews (also custom, XIB-designed).
The VC has a UITapGestureRecognizer on that custom view, and in my #IBAction, I want to identify which specific one of its subviews was tapped, then handle it accordingly:
#IBAction func handleTap(recognizer:UITapGestureRecognizer) {
let tapLocation = recognizer.location(in: recognizer.view)
if let subviewTapped = customView.subviewAtLocation(tapLocation) {
handleTapForSubview(subviewTapped)
}
}
However, I don't know how to implement that subviewAtLocation(CGPoint) method. I wasn't able to find anything in the standard UIView methods.
Any advice on how it should be done would be welcome.
Alternatively, I considered adding a tap recognizer to each of the subviews instead, then delegating to the parent, then to the VC, but that feels inefficient, and like it's putting too much control logic in the views, rather than the VC.
Thank you.
A solution would be using the contains(point:) method of CGRect. The idea is to iterate over the stack view's subviews and check which subviews contains the touched point. Here:
#IBAction func handleTap(recognizer:UITapGestureRecognizer) {
let tapLocation = recognizer.location(in: recognizer.view)
let filteredSubviews = stackView.subviews.filter { subView -> Bool in
return subView.frame.contains(tapLocation)
}
guard let subviewTapped = filteredSubviews.first else {
// No subview touched
return
}
// process subviewTapped however you want
}
//use hitTest() method. this gives the view which contain the point
let subView = parentView.hitTest(point, with: nil)
In my app, I am displaying a popover that shows a custom UIView allowing the user to select a color using various sliders. I also want to implement an 'eyedropper' tool that the user can tap and hold then drag around to choose a color from anything visible in the app. Inside my custom UIView I added a UIPanGestureRecognizer to my button that points to the handlePan method:
var eyedropperStartLocation = CGPoint.zero
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
// self is a custom UIView that contains my color selection
// sliders and is placed inside a UITableView that's in
// the popover.
let translation = recognizer.translation(in: self)
if let view = recognizer.view {
switch recognizer.state {
case .began:
eyedropperStartLocation = view.center
case .ended, .failed, .cancelled:
view.center = eyedropperStartLocation
return
default: break
}
view.center = CGPoint(x: view.center.x + translation.x,
y: view.center.y + translation.y)
recognizer.setTranslation(CGPoint.zero, in: self)
}
}
I can drag the button around and it changes location, however I have two issues:
The eyedropper button isn't always in front of other items, even inside the popover or the custom UIView inside the popover
The eyedropper button disappears when outside the bounds of the popover
How can I get the button to be visible all the time, including outside the popover? I'll want to detect where the user lets go of it within the app so I can determine what color it was on.
I figured out how to do this so I'll answer my own question. Instead of moving around the view/button that's inside the popup, I create a new UIImageView and add it to the application's Window, letting it span the whole application. The original button stays where it is - you could easily change the state on it to make it look different, or hide it if you wanted to.
You could also use Interface Builder to tie to #IBActions, but I just did everything in code. The clickButton method kicks things off but calculating location in the window and putting it on the screen. The handlePan method does the translation and lets you move it around.
All code below is swift 4 and works in XCode 9.4.1 (assuming I didn't introduce any typos):
// Call this from all your init methods so it will always happen
func commonInit() {
let panner = UIPanGestureRecognizer(target: self, action: #selector(handlePan(recognizer:)))
theButton.addGestureRecognizer(panner)
theButton.addTarget(self, action: #selector(clickButton), for: .touchDown)
eyedropperButton.addTarget(self, action: #selector(unclickButton), for: .touchUpInside)
eyedropperButton.addTarget(self, action: #selector(unclickButton), for: .touchUpOutside)
}
var startLocation = CGPoint.zero
lazy var floatingView: UIImageView = {
let view = UIImageView(image: UIImage(named: "imagename"))
view.backgroundColor = UIColor.blue
return view
}()
// When the user clicks button - we create the image and put it on the screen
// this makes the action seem faster vs waiting for the panning action to kick in
#objc func clickButton() {
guard let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window else { return }
// We ask the button what it's bounds are within it's own coordinate system and convert that to the
// Window's coordinate system and set the frame on the floating view. This makes the new view overlap the
// button exactly.
floatingView.frame = theButton.convert(theButton.bounds, to: nil)
window.addSubview(floatingView)
// Save the location so we can translate it as part of the pan actions
startLocation = floatingView.center
}
// This is here to handle the case where the user didn't move enough to kick in the panGestureRecognizer and cancel the action
#objc func unclickButton() {
floatingView.removeFromSuperview()
}
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
switch recognizer.state {
case .ended, .failed, .cancelled:
doSomething()
floatingView.removeFromSuperview()
return
default: break
}
// This section is called for any pan state except .ended, .failed and .cancelled
floatingView.center = CGPoint(x: floatingView.center.x + translation.x,
y: floatingView.center.y + translation.y)
recognizer.setTranslation(CGPoint.zero, in: self)
}
For an example if i have multiple images on views in random position. Images are selected by drawing lines on it and group images by using gestures. Right now i can able to show images randomly but not able group images by drawing line on it.
Here screenshot 1 is result which i have getting now:
screenshot 2 which is exactly what i want.
For what you are trying to do I would start by creating a custom view (a subclass) that is able to handle gestures and draw paths.
For gesture recognizer I would use UIPanGestureRecognizer. What you do is have an array of points where the gesture was handled which are then used to draw the path:
private var currentPathPoints: [CGPoint] = []
#objc private func onPan(_ sender: UIGestureRecognizer) {
switch sender.state {
case .began: currentPathPoints = [sender.location(in: self)] // Reset current array by only showing a current point. User just started his path
case .changed: currentPathPoints.append(sender.location(in: self)) // Just append a new point
case .cancelled, .ended: endPath() // Will need to report that user lifted his finger
default: break // These extra states are her just to annoy us
}
}
So if this method is used by pan gesture recognizer it should track points where user is dragging. Now these are best drawn in drawRect which needs to be overridden in your view like:
override func draw(_ rect: CGRect) {
super.draw(rect)
// Generate path
let path: UIBezierPath = {
let path = UIBezierPath()
var pointsToDistribute = currentPathPoints
if let first = pointsToDistribute.first {
path.move(to: first)
pointsToDistribute.remove(at: 0)
}
pointsToDistribute.forEach { point in
path.addLine(to: point)
}
return path
}()
let color = UIColor.red // TODO: user your true color
color.setStroke()
path.lineWidth = 3.0
path.stroke()
}
Now this method will be called when you invalidate drawing by calling setNeedsDisplay. In your case that is best done on setter of your path points:
private var currentPathPoints: [CGPoint] = [] {
didSet {
setNeedsDisplay()
}
}
Since this view should be as an overlay to your whole scene you need some way to reporting the events back. A delegate procedure should be created that implements methods like:
func endPath() {
delegate?.myLineView(self, finishedPath: currentPathPoints)
}
So now if view controller is a delegate it can check which image views were selected within the path. For first version it should be enough to just check if any of the points is within any of the image views:
func myLineView(sender: MyLineView, finishedPath pathPoints: [CGPoint]) {
let convertedPoints: [CGPoint] = pathPoints.map { sender.convert($0, to: viewThatContainsImages) }
let imageViewsHitByPath = allImageViews.filter { imageView in
return convertedPoints.contains(where: { imageView.frame.contains($0) })
}
// Use imageViewsHitByPath
}
Now after this basic implementation you can start playing by drawing a nicer line (curved) and with cases where you don't check if a point is inside image view but rather if a line between any 2 neighbor points intersects your image view.
Edited
See the comment section with Nathan for the latest project. There is only problem remaining: getting the right button.
Edited
I want to have a UIView that the user can rotate. That UIView should contain some UIButtons that can be clicked. I am having a hard time because I am using a UIControl subclass to make the rotating view and in that subclass I have to disable user interactions on the subviews in the UIControl (to make it spin) which may cause the UIButtons not be tappable. How can I make a UIView that the user can spin and contains clickable UIButtons? This is a link to my project which gives you a head start: it contains the UIButtons and a spinnable UIView. I can however not tap the UIButtons.
Old question with more details
I am using this pod: https://github.com/joshdhenry/SpinWheelControl and I want to react to a buttons click. I can add the button, however I can not receive tap events in the button. I am using hitTests but they never get executed. The user should spin the wheel and be able to click a button in one of the pie's.
Get the project here: https://github.com/Jasperav/SpinningWheelWithTappableButtons
See the code below what I added in the pod file:
I added this variable in SpinWheelWedge.swift:
let button = SpinWheelWedgeButton()
I added this class:
class SpinWheelWedgeButton: TornadoButton {
public func configureWedgeButton(index: UInt, width: CGFloat, position: CGPoint, radiansPerWedge: Radians) {
self.frame = CGRect(x: 0, y: 0, width: width, height: 30)
self.layer.anchorPoint = CGPoint(x: 1.1, y: 0.5)
self.layer.position = position
self.transform = CGAffineTransform(rotationAngle: radiansPerWedge * CGFloat(index) + CGFloat.pi + (radiansPerWedge / 2))
self.backgroundColor = .green
self.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside)
}
#IBAction func pressed(_ sender: TornadoButton){
print("hi")
}
}
This is the class TornadoButton:
class TornadoButton: UIButton{
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
let prespt = self.superview!.layer.convert(suppt, to: pres)
if (pres.hitTest(suppt)) != nil{
return self
}
return super.hitTest(prespt, with: event)
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
return (pres.hitTest(suppt)) != nil
}
}
I added this to SpinWheelControl.swift, in the loop "for wedgeNumber in"
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 2, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
This is where I thought I could retrieve the button, in SpinWheelControl.swift:
override open func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let p = touch.location(in: touch.view)
let v = touch.view?.hitTest(p, with: nil)
print(v)
}
Only 'v' is always the spin wheel itself, never the button. I also do not see the buttons print, and the hittest is never executed. What is wrong with this code and why does the hitTest not executes? I rather have a normal UIBUtton, but I thought I needed hittests for this.
Here is a solution for your specific project:
Step 1
In the drawWheel function in SpinWheelControl.swift, enable user interaction on the spinWheelView. To do this, remove the following line:
self.spinWheelView.isUserInteractionEnabled = false
Step 2
Again in the drawWheel function, make the button a subview of the spinWheelView, not the wedge. Add the button as a subview after the wedge, so it will appear on top of the wedge shape layer.
Old:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
spinWheelView.addSubview(wedge)
New:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
spinWheelView.addSubview(wedge)
spinWheelView.addSubview(wedge.button)
Step 3
Create a new UIView subclass that passes touches through to its subviews.
class PassThroughView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subview in subviews {
if !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) {
return true
}
}
return false
}
}
Step 4
At the very beginning of the drawWheel function, declare the spinWheelView to be of type PassThroughView. This will allow the buttons to receive touch events.
spinWheelView = PassThroughView(frame: self.bounds)
With those few changes, you should get the following behavior:
(The message is printed to the console when any button is pressed.)
Limitations
This solution allows the user to spin the wheel as usual, as well as tap any of the buttons. However, this might not be the perfect solution for your needs, as there are some limitations:
The wheel cannot be spun if the users touch down starts within the bounds of any of the buttons.
The buttons can be pressed while the wheel is in motion.
Depending on your needs, you might consider building your own spinner instead of relying on a third-party pod. The difficulty with this pod is that it is using the beginTracking(_ touch: UITouch, with event: UIEvent?) and related functions instead of gesture recognizers. If you used gesture recognizers, it would be easier to make use of all the UIButton functionality.
Alternatively, if you just wanted to recognize a touch down event within the bounds of a wedge, you could pursue your hitTest idea further.
Edit: Determining which button was pressed.
If we know the selectedIndex of the wheel and the starting selectedIndex, we can calculate which button was pressed.
Currently, the starting selectedIndex is 0, and the button tags increase going clockwise. Tapping the selected button (tag = 0), prints 7, which means that the buttons are "rotated" 7 positions in their starting state. If the wheel started in a different position, this value would differ.
Here is a quick function to determine the tag of the button that was tapped using two pieces of information: the wheel's selectedIndex and the subview.tag from the current point(inside point: CGPoint, with event: UIEvent?) implementation of the PassThroughView.
func determineButtonTag(selectedIndex: Int, subviewTag: Int) -> Int {
return subviewTag + (selectedIndex - 7)
}
Again, this is definitely a hack, but it works. If you are planning to continue to add functionality to this spinner control, I would highly recommend creating your own control instead so you can design it from the beginning to fit your needs.
I was able to tinker around with the project and I think I have the solution to your problem.
In your SpinWheelControl class, you are setting the userInteractionEnabled property of the spinWheelViews to false. Note that this is not what you exactly want, because you are still interested in tapping the button which is inside the spinWheelView. However, if you don't turn off user interaction, the wheel won't turn because the child views mess up the touches!
To solve this problem, we can turn off the user interaction for the child views and manually trigger only the events that we are interested in - which is basically touchUpInside for the innermost button.
The easiest way to do that is in the endTracking method of the SpinWheelControl. When the endTracking method gets called, we loop through all the buttons manually and call endTracking for them as well.
Now the problem about which button was pressed remains, because we just sent endTracking to all of them. The solution to that is overriding the endTracking method of the buttons and trigger the .touchUpInside method manually only if the touch hitTest for that particular button was true.
Code:
TornadoButton Class: (the custom hitTest and pointInside are no longer needed since we are no longer interested in doing the usual hit testing; we just directly call endTracking)
class TornadoButton: UIButton{
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if let t = touch {
if self.hitTest(t.location(in: self), with: event) != nil {
print("Tornado button with tag \(self.tag) ended tracking")
self.sendActions(for: [.touchUpInside])
}
}
}
}
SpinWheelControl Class: endTracking method:
override open func endTracking(_ touch: UITouch?, with event: UIEvent?) {
for sv in self.spinWheelView.subviews {
if let wedge = sv as? SpinWheelWedge {
wedge.button.endTracking(touch, with: event)
}
}
...
}
Also, to test that the right button is being called, just set the tag of the button equal to the wedgeNumber when you are creating them. With this method, you will not need to use the custom offset like #nathan does, because the right button will respond to the endTracking and you can just get its tag by sender.tag.
The general solution would be to use a UIView and place all your UIButtons where they should be, and use a UIPanGestureRecognizer to rotate your view, calculate speed and direction vector and rotate your view. For rotating your view I suggest using transform because it's animatable and also your subviews will be also rotated. (extra: If you want to set direction of your UIButtons always downward, just rotate them in reverse, it will cause them to always look downward)
Hack
Some people also use UIScrollView instead of UIPanGestureRecognizer. Place described View inside the UIScrollView and use UIScrollView's delegate methods to calculate speed and direction then apply those values to your UIView as described. The reason for this hack is because UIScrollView decelerates speed automatically and provides better experience. (Using this technique you should set contentSize to something very big and relocate contentOffset of UIScrollView to .zero periodically.
But I highly suggest the first approach.
As for my opinion, you can use your own view with few sublayers and all other stuff you need.
In this case u will get full flexibility but you also should write a little bit more code.
If you like this option u can get something like on gif below (you can customize it as u wish - add text, images, animations etc):
Here I show you 2 continuous pan and one tap on purple section - when tap is detected6 bg color changed to green
To detect tap I used touchesBegan as shown below.
To play with code for this you can copy-paste code below in to playground and modify as per your needs
//: A UIKit based Playground for presenting user interface
import UIKit import PlaygroundSupport
class RoundView : UIView {
var sampleArcLayer:CAShapeLayer = CAShapeLayer()
func performRotation( power: Float) {
let maxDuration:Float = 2
let maxRotationCount:Float = 5
let currentDuration = maxDuration * power
let currrentRotationCount = (Double)(maxRotationCount * power)
let fromValue:Double = Double(atan2f(Float(transform.b), Float(transform.a)))
let toValue = Double.pi * currrentRotationCount + fromValue
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = fromValue
rotateAnimation.toValue = toValue
rotateAnimation.duration = CFTimeInterval(currentDuration)
rotateAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
rotateAnimation.isRemovedOnCompletion = true
layer.add(rotateAnimation, forKey: nil)
layer.transform = CATransform3DMakeRotation(CGFloat(toValue), 0, 0, 1)
}
override func layoutSubviews() {
super.layoutSubviews()
drawLayers()
}
private func drawLayers()
{
sampleArcLayer.removeFromSuperlayer()
sampleArcLayer.frame = bounds
sampleArcLayer.fillColor = UIColor.purple.cgColor
let proportion = CGFloat(20)
let centre = CGPoint (x: frame.size.width / 2, y: frame.size.height / 2)
let radius = frame.size.width / 2
let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle
let startAngle:CGFloat = 45
let cPath = UIBezierPath()
cPath.move(to: centre)
cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
sampleArcLayer.path = cPath.cgPath
// you can add CATExtLayer and any other stuff you need
layer.addSublayer(sampleArcLayer)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let point = touches.first?.location(in: self) {
if let layerArray = layer.sublayers {
for sublayer in layerArray {
if sublayer.contains(point) {
if sublayer == sampleArcLayer {
if sampleArcLayer.path?.contains(point) == true {
backgroundColor = UIColor.green
}
}
}
}
}
}
}
}
class MyViewController : UIViewController {
private var lastTouchPoint:CGPoint = CGPoint.zero
private var initialTouchPoint:CGPoint = CGPoint.zero
private let testView:RoundView = RoundView(frame:CGRect(x: 40, y: 40, width: 100, height: 100))
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
testView.layer.cornerRadius = testView.frame.height / 2
testView.layer.masksToBounds = true
testView.backgroundColor = UIColor.red
view.addSubview(testView)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(MyViewController.didDetectPan(_:)))
testView.addGestureRecognizer(panGesture)
}
#objc func didDetectPan(_ gesture:UIPanGestureRecognizer) {
let touchPoint = gesture.location(in: testView)
switch gesture.state {
case .began:
initialTouchPoint = touchPoint
break
case .changed:
lastTouchPoint = touchPoint
break
case .ended, .cancelled:
let delta = initialTouchPoint.y - lastTouchPoint.y
let powerPercentage = max(abs(delta) / testView.frame.height, 1)
performActionOnView(scrollPower: Float(powerPercentage))
initialTouchPoint = CGPoint.zero
break
default:
break
}
}
private func performActionOnView(scrollPower:Float) {
testView.performRotation(power: scrollPower)
} } // Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Can anyone tell me how I can mimic the bottom sheet in the new Apple Maps app in iOS 10?
In Android, you can use a BottomSheet which mimics this behaviour, but I could not find anything like that for iOS.
Is that a simple scroll view with a content inset, so that the search bar is at the bottom?
I am fairly new to iOS programming so if someone could help me creating this layout, that would be highly appreciated.
This is what I mean by "bottom sheet":
I don't know how exactly the bottom sheet of the new Maps app, responds to user interactions. But you can create a custom view that looks like the one in the screenshots and add it to the main view.
I assume you know how to:
1- create view controllers either by storyboards or using xib files.
2- use googleMaps or Apple's MapKit.
Example
1- Create 2 view controllers e.g, MapViewController and BottomSheetViewController. The first controller will host the map and the second is the bottom sheet itself.
Configure MapViewController
Create a method to add the bottom sheet view.
func addBottomSheetView() {
// 1- Init bottomSheetVC
let bottomSheetVC = BottomSheetViewController()
// 2- Add bottomSheetVC as a child view
self.addChildViewController(bottomSheetVC)
self.view.addSubview(bottomSheetVC.view)
bottomSheetVC.didMoveToParentViewController(self)
// 3- Adjust bottomSheet frame and initial position.
let height = view.frame.height
let width = view.frame.width
bottomSheetVC.view.frame = CGRectMake(0, self.view.frame.maxY, width, height)
}
And call it in viewDidAppear method:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
addBottomSheetView()
}
Configure BottomSheetViewController
1) Prepare background
Create a method to add blur and vibrancy effects
func prepareBackgroundView(){
let blurEffect = UIBlurEffect.init(style: .Dark)
let visualEffect = UIVisualEffectView.init(effect: blurEffect)
let bluredView = UIVisualEffectView.init(effect: blurEffect)
bluredView.contentView.addSubview(visualEffect)
visualEffect.frame = UIScreen.mainScreen().bounds
bluredView.frame = UIScreen.mainScreen().bounds
view.insertSubview(bluredView, atIndex: 0)
}
call this method in your viewWillAppear
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
prepareBackgroundView()
}
Make sure that your controller's view background color is clearColor.
2) Animate bottomSheet appearance
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
UIView.animateWithDuration(0.3) { [weak self] in
let frame = self?.view.frame
let yComponent = UIScreen.mainScreen().bounds.height - 200
self?.view.frame = CGRectMake(0, yComponent, frame!.width, frame!.height)
}
}
3) Modify your xib as you want.
4) Add Pan Gesture Recognizer to your view.
In your viewDidLoad method add UIPanGestureRecognizer.
override func viewDidLoad() {
super.viewDidLoad()
let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
view.addGestureRecognizer(gesture)
}
And implement your gesture behaviour:
func panGesture(recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translationInView(self.view)
let y = self.view.frame.minY
self.view.frame = CGRectMake(0, y + translation.y, view.frame.width, view.frame.height)
recognizer.setTranslation(CGPointZero, inView: self.view)
}
Scrollable Bottom Sheet:
If your custom view is a scroll view or any other view that inherits from, so you have two options:
First:
Design the view with a header view and add the panGesture to the header. (bad user experience).
Second:
1 - Add the panGesture to the bottom sheet view.
2 - Implement the UIGestureRecognizerDelegate and set the panGesture delegate to the controller.
3- Implement shouldRecognizeSimultaneouslyWith delegate function and disable the scrollView isScrollEnabled property in two case:
The view is partially visible.
The view is totally visible, the scrollView contentOffset property is 0 and the user is dragging the view downwards.
Otherwise enable scrolling.
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
let direction = gesture.velocity(in: view).y
let y = view.frame.minY
if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
tableView.isScrollEnabled = false
} else {
tableView.isScrollEnabled = true
}
return false
}
NOTE
In case you set .allowUserInteraction as an animation option, like in the sample project, so you need to enable scrolling on the animation completion closure if the user is scrolling up.
Sample Project
I created a sample project with more options on this repo which may give you better insights about how to customise the flow.
In the demo, addBottomSheetView() function controls which view should be used as a bottom sheet.
Sample Project Screenshots
- Partial View
- FullView
- Scrollable View
Update iOS 15
In iOS 15, you can now use the native UISheetPresentationController.
if let sheet = viewControllerToPresent.sheetPresentationController {
sheet.detents = [.medium(), .large()]
// your sheet setup
}
present(viewControllerToPresent, animated: true, completion: nil)
Notice that you can even reproduce its navigation stack using the overcurrentcontext presentation mode:
let nextViewControllerToPresent: UIViewController = ...
nextViewControllerToPresent.modalPresentationStyle = .overCurrentContext
viewControllerToPresent.present(nextViewControllerToPresent, animated: true, completion: nil)
Legacy
I released a library based on my answer below.
It mimics the Shortcuts application overlay. See this article for details.
The main component of the library is the OverlayContainerViewController. It defines an area where a view controller can be dragged up and down, hiding or revealing the content underneath it.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Implement OverlayContainerViewControllerDelegate to specify the number of notches wished:
enum OverlayNotch: Int, CaseIterable {
case minimum, medium, maximum
}
func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
return OverlayNotch.allCases.count
}
func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
heightForNotchAt index: Int,
availableSpace: CGFloat) -> CGFloat {
switch OverlayNotch.allCases[index] {
case .maximum:
return availableSpace * 3 / 4
case .medium:
return availableSpace / 2
case .minimum:
return availableSpace * 1 / 4
}
}
SwiftUI (12/29/20)
A SwiftUI version of the library is now available.
Color.red.dynamicOverlay(Color.green)
Previous answer
I think there is a significant point that is not treated in the suggested solutions: the transition between the scroll and the translation.
In Maps, as you may have noticed, when the tableView reaches contentOffset.y == 0, the bottom sheet either slides up or goes down.
The point is tricky because we can not simply enable/disable the scroll when our pan gesture begins the translation. It would stop the scroll until a new touch begins. This is the case in most of the proposed solutions here.
Here is my try to implement this motion.
Starting point: Maps App
To start our investigation, let's visualize the view hierarchy of Maps (start Maps on a simulator and select Debug > Attach to process by PID or Name > Maps in Xcode 9).
It doesn't tell how the motion works, but it helped me to understand the logic of it. You can play with the lldb and the view hierarchy debugger.
Our view controller stacks
Let's create a basic version of the Maps ViewController architecture.
We start with a BackgroundViewController (our map view):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
We put the tableView in a dedicated UIViewController:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Now, we need a VC to embed the overlay and manage its translation.
To simplify the problem, we consider that it can translate the overlay from one static point OverlayPosition.maximum to another OverlayPosition.minimum.
For now it only has one public method to animate the position change and it has a transparent view:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Finally we need a ViewController to embed the all:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
In our AppDelegate, our startup sequence looks like:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
The difficulty behind the overlay translation
Now, how to translate our overlay?
Most of the proposed solutions use a dedicated pan gesture recognizer, but we actually already have one : the pan gesture of the table view.
Moreover, we need to keep the scroll and the translation synchronised and the UIScrollViewDelegate has all the events we need!
A naive implementation would use a second pan Gesture and try to reset the contentOffset of the table view when the translation occurs:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
But it does not work. The tableView updates its contentOffset when its own pan gesture recognizer action triggers or when its displayLink callback is called. There is no chance that our recognizer triggers right after those to successfully override the contentOffset.
Our only chance is either to take part of the layout phase (by overriding layoutSubviews of the scroll view calls at each frame of the scroll view) or to respond to the didScroll method of the delegate called each time the contentOffset is modified. Let's try this one.
The translation Implementation
We add a delegate to our OverlayVC to dispatch the scrollview's events to our translation handler, the OverlayContainerViewController :
protocol OverlayViewControllerDelegate: class {
func scrollViewDidScroll(_ scrollView: UIScrollView)
func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}
class OverlayViewController: UIViewController {
[...]
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
delegate?.scrollViewDidStopScrolling(scrollView)
}
}
In our container, we keep track of the translation using a enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
The current position calculation looks like :
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
We need 3 methods to handle the translation:
The first one tells us if we need to start the translation.
private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
guard scrollView.isTracking else { return false }
let offset = scrollView.contentOffset.y
switch overlayInFlightPosition {
case .maximum:
return offset < 0
case .minimum:
return offset > 0
case .progressing:
return true
}
}
The second one performs the translation. It uses the translation(in:) method of the scrollView's pan gesture.
private func translateView(following scrollView: UIScrollView) {
scrollView.contentOffset = .zero
let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
translatedViewHeightContraint.constant = max(
Constant.minimumHeight,
min(translation, Constant.maximumHeight)
)
}
The third one animates the end of the translation when the user releases its finger. We calculate the position using the velocity & the current position of the view.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Our overlay's delegate implementation simply looks like :
class OverlayContainerViewController: UIViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldTranslateView(following: scrollView) else { return }
translateView(following: scrollView)
}
func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
// prevent scroll animation when the translation animation ends
scrollView.isEnabled = false
scrollView.isEnabled = true
animateTranslationEnd()
}
}
Final problem: dispatching the overlay container's touches
The translation is now pretty efficient. But there is still a final problem: the touches are not delivered to our background view. They are all intercepted by the overlay container's view.
We can not set isUserInteractionEnabled to false because it would also disable the interaction in our table view. The solution is the one used massively in the Maps app, PassThroughView:
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return nil
}
return view
}
}
It removes itself from the responder chain.
In OverlayContainerViewController:
override func loadView() {
view = PassThroughView()
}
Result
Here is the result:
You can find the code here.
Please if you see any bugs, let me know ! Note that your implementation can of course use a second pan gesture, specially if you add a header in your overlay.
Update 23/08/18
We can replace scrollViewDidEndDragging with
willEndScrollingWithVelocity rather than enabling/disabling the scroll when the user ends dragging:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
We can use a spring animation and allow user interaction while animating to make the motion flow better:
func moveOverlay(to position: OverlayPosition,
duration: TimeInterval,
velocity: CGPoint) {
overlayPosition = position
translatedViewHeightContraint.constant = translatedViewTargetHeight
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
initialSpringVelocity: abs(velocity.y),
options: [.allowUserInteraction],
animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
Try Pulley:
Pulley is an easy to use drawer library meant to imitate the drawer
in iOS 10's Maps app. It exposes a simple API that allows you to use
any UIViewController subclass as the drawer content or the primary
content.
https://github.com/52inc/Pulley
I wrote my own library to achieve the intended behaviour in ios Maps app. It is a protocol oriented solution. So you don't need to inherit any base class instead create a sheet controller and configure as you wish. It also supports inner navigation/presentation with or without UINavigationController.
See below link for more details.
https://github.com/OfTheWolf/UBottomSheet
You can try my answer https://github.com/SCENEE/FloatingPanel. It provides a container view controller to display a "bottom sheet" interface.
It's easy to use and you don't mind any gesture recognizer handling! Also you can track a scroll view's(or the sibling view) in a bottom sheet if needed.
This is a simple example. Please note that you need to prepare a view controller to display your content in a bottom sheet.
import UIKit
import FloatingPanel
class ViewController: UIViewController {
var fpc: FloatingPanelController!
override func viewDidLoad() {
super.viewDidLoad()
fpc = FloatingPanelController()
// Add "bottom sheet" in self.view.
fpc.add(toParent: self)
// Add a view controller to display your contents in "bottom sheet".
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// Track a scroll view in "bottom sheet" content if needed.
fpc.track(scrollView: contentVC.tableView)
}
...
}
Here is another example code to display a bottom sheet to search a location like Apple Maps.
iOS 15 in 2021 adds UISheetPresentationController, which is Apple's first public release of an Apple Maps-style "bottom sheet":
UISheetPresentationController
UISheetPresentationController lets you present your view controller as a sheet. Before you present your view controller, configure its sheet presentation controller with the behavior and appearance you want for your sheet.
Sheet presentation controllers specify a sheet's size based on a detent, a height where a sheet naturally rests. Detents allow a sheet to resize from one edge of its fully expanded frame while the other three edges remain fixed. You specify the detents that a sheet supports using detents, and monitor its most recently selected detent using selectedDetentIdentifier.
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
This new bottom sheet control is explored in WWDC Session 10063: Customize and Resize Sheets in UIKit
Unfortunately....
In iOS 15, the UISheetPresentationController has launched with only medium and large detents.
A small detent is notably absent from the iOS 15 API, which would be required to display an always-presented "collapsed" bottom sheet like Apple Maps:
Custom smaller Detents in UISheetPresentationController?
The medium detent was released to handle use cases such as the Share Sheet or the "••• More" menu in Mail: a button-triggered half sheet.
In iOS 15, Apple Maps is now using this UIKit sheet presentation rather than a custom implementation, which is a huge step in the right direction. Apple Maps in iOS 15 continues to show the "small" bar, as well as a 1/3rd height bar. But those view sizes are not public API available to developers.
UIKit engineers at the WWDC 2021 Labs seemed to know that a small detent would be a hugely popular UIKit component. I would expect to see an API expansion for iOS 16 next year.
We’ve just released a pure Swift Package supporting iOS 11.4+ which provides you a BottomSheet with theme and behavior options you can customize. This component is easy to use, and flexible. You can find it here: https://github.com/LunabeeStudio/LBBottomSheet.
A demo project is available in this repository too.
For example, it supports different ways to manage the needed height, and also adds to the controller behind it the ability to detect height changes and adapt its bottom content inset.
You can find more information on the GitHub repository and in the documentation: https://lbbottomsheet.lunabee.studio.
I think it can help you to do what you’re looking for. Don’t hesitate to tell me if you have comments/questions :)
Here you can see one of all the possible BottomSheet configurations:
**for iOS 15 Native Support available for this **
#IBAction func openSheet() {
let secondVC = self.storyboard?.instantiateViewController(withIdentifier: "SecondViewController")
// Create the view controller.
if #available(iOS 15.0, *) {
let formNC = UINavigationController(rootViewController: secondVC!)
formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
guard let sheetPresentationController = formNC.presentationController as? UISheetPresentationController else {
return
}
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.prefersGrabberVisible = true
present(formNC, animated: true, completion: nil)
} else {
// Fallback on earlier versions
}
}
iOS 15 finally adds a native UISheetPresentationController!
Official documentation
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
I recently created a component called SwipeableView as subclass of UIView, written in Swift 5.1 . It support all 4 direction, has several customisation options and can animate and interpolate different attributes and items ( such as layout constraints, background/tint color, affine transform, alpha channel and view center, all of them demoed with the respective show case ). It also supports the swiping coordination with the inner scroll view if set or auto detected. Should be pretty easy and straightforward to be used ( I hope 🙂)
Link at https://github.com/LucaIaco/SwipeableView
proof of concept:
Hope it helps
If you are looking for a SwiftUI 2.0 solution that uses View Struct, here it is:
https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet
Maybe you can try my answer https://github.com/AnYuan/AYPannel, inspired by Pulley. Smooth transition from moving the drawer to scrolling the list. I added a pan gesture on the container scroll view, and set shouldRecognizeSimultaneouslyWithGestureRecognizer to return YES. More detail in my github link above. Wish to help.