MKMapView map annotations with expandable info view
In previous blog post, we made a mapView, which dynamically centered on annotations while we dragged the info view upwards.
Requirement
This time we need to adjust our info view, to be able to show custom amount of data. By default it should be collapsed at the bottom (revealing small amount of data), but user should be able to expand it, by dragging upwards, to reveal more information.
I want to do it by using only a scrollView, and avoid implementing gesture recognizers with custom logic. ScrollView has all the functionality we need to do what is necessary - it just needs some tweaking.
Solution
The way we will implement this:
1.) Have scrollView above mapView;
2.) ScrollView will have contentInset from the top (so that data appears as collapsed at the bottom);
3.) ScrollView will let touches through in places where there is no content.
First we create a subclass, that will let touches on scrollview throught to mapView, in places where scrollView is empty (has no subviews). That way - even though we have our scrollview almost completely over our mapView, touches will go through at places where scrollView has no content.
class PassThroughScrollView: UIScrollView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return subviews.contains(where: {
!$0.isHidden
&& $0.isUserInteractionEnabled
&& $0.point(inside: self.convert(point, to: $0), with: event)
})
}
}
Then we add a scrollview PassThroughScrollView
to our storyboard. We place it as a subview to ViewControllers view. As you can see in the image - it’s covering almost all view (above mapView), except it will have 150 px offset from top. This will ensure, that when infoView will be fully expanded, we will still see a little bit of map at the top (and annotations).
Next we add an info view as a subview to scrollView. It’s just some view, that has an imageView and title label.
infoView = InfoView.init()
scrollView.addSubview(infoView)
infoView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
infoView.leftAnchor.constraint(equalTo: scrollView.leftAnchor).isActive = true
infoView.rightAnchor.constraint(equalTo: scrollView.rightAnchor).isActive = true
infoView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
infoView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
// Some demo text...
infoView.titleLabel.text = "Info text comes here\n\nInfo text comes here..."
infoView.layoutIfNeeded()
Ok, now we have scrollview with info view in it, but we need all this content to appear collapsed/minimised at the bottom. To achieve that - we need to know the height of the collapsed content block. I chose 150 px.
scrollView.contentInset = UIEdgeInsets(top: scrollView.frame.height - min(150, infoView.frame.height) - self.view.safeAreaInsets.bottom, left: 0, bottom: 0, right: 0)
So, our cheat is that we just added a content offset from the top. Visually it looks like scrollview just starts at the bottom, but actually, if you notice (scroll indicators on the right side), it starts right where we placed it.
Sure, we could just remove scroll indicators, or use some custom ones, or maybe even enable them only when we have expanded our info view. But that’s no fun.
Let’s think about it. Basically, we need to have scroll indicators start from the collapsed position. So, we just set in our scrollViewDidScroll()
function, to have insets based on contentOffset. (Our added contentInset, makes the contentOffset.y be negative, until it scrolls to where real content starts).
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.scrollIndicatorInsets = UIEdgeInsets(top:max(0, -scrollView.contentOffset.y), left: 0, bottom: 0, right: 0)
}
It’s now great! But not perfect. You see - when we expand our scrollview, indicators show, that we are now in the middle of our content. But that’s not true! Well. it is true because we have invisible scrollView content offset. But visually it appears flawed.
To fix it, we add a little bit of magic. We need to calculate adjustment, based on the ratio of scroll view height versus contentSize. Then use this ratio with the contentInset.top (or current contentOffset.y) (which ever is the smallest), to subtract this value for scrollIndicator’s top inset. That way, we will correctly calculate correct inset and scroll indicator will adjust it’s size, but keep it’s position at the start (0 positon), until info view is fully expanded.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let frameHeight = scrollView.frame.height
- self.view.safeAreaInsets.bottom
- max(0, -scrollView.contentOffset.y)
let contentHeight = scrollView.contentSize.height
+ self.view.safeAreaInsets.bottom
+ max(0, -scrollView.contentOffset.y)
// Some magic, to calculate proper scroll indicator top inset,
// so that it would be at 0 position at all times,
// until full page is revealed.
let ratio = min(1, frameHeight / contentHeight)
let value = max(0, min(scrollView.contentInset.top,
scrollView.contentInset.top + scrollView.contentOffset.y))
scrollView.scrollIndicatorInsets =
UIEdgeInsets(top: max(0, -scrollView.contentOffset.y) - value * ratio,
left: 0, bottom: 0, right: 0)
}
Just one more thing. I love how scrollView paging works. It kinda splits whole contentSize in pages, that are as large as scrollView frame. Paging then also gives us the “springy” feeling to jump to positions. That is exactly what I want - for it to collapsed/expanded positions.
But I don’t want it to be paged afterwards (when scrolling in expanded size). To fix it, we will first need to set up scrollView.isPagingEnabled = true
when we set up our viewController, but afterwards, we also implement:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
scrollView.isPagingEnabled = scrollView.contentOffset.y <= 0
}
Thus - it will make scrollView with paging enabled while we scroll from collapsed view to expanded (because then the contentOffset.y is negative), and later on, it will turn it off, and give us a smooth and free scrolling.
Annd… Done!