UIScrollView: Lock scrolling to vertical axis when zoomed in without changing the contentView - ios

What’s the best way to dynamically lock scrolling in a UIScrollView to the vertical axis depending on the zoom scale?
I want to allow scrolling a large canvas in any direction when zoomed out (scrollView.zoomScale < 1.0)
but prevent horizontal scrolling completely when zoomed in (scrollView.zoomScale == 1.0).
The challenge here is that UIScrollView doesn’t seem to have a built-in setting to limit scrolling to one direction if the contentView is larger than the viewport in both directions. I would like to use the same large contentView but disallow horizontal scrolling when zoomed in.
(I know about scrollView.isDirectionalLockEnabled, but that’s not what I need: It only checks whether the user’s pan gesture has a dominant scrolling direction and then dynamically locks scrolling to either direction.)
Thanks!

If I understand your goal correctly...
You have a "contentView" that is larger than the scroll view
if the zoom scale is 1.0, only allow vertical scrolling
if the zoom scale is less than 1.0, allow both vertical and horizontal scrolling
So, if we have a scroll view frame size of 388 x 661 and a "contentView" with a size of 2100 x 2100, we start like this at zoom scale 1.0 (the bright-green is the scroll view frame):
and only vertical scrolling is allowed.
If the user zooms-out to, say, 0.8 scale:
both vertical and horizontal scrolling is allowed.
If the user then zooms-in back to 1.0 scale:
we're back to only vertical scrolling.
You can accomplish that by conforming your controller to UIScrollViewDelegate, assign self as the scrollView's delegate, add a "last scrollView content offset X" var, and then implement scrollViewDidScroll():
var lastOffsetX: CGFloat = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// if zoom scale is 1.0
// don't allow horizontal scrolling
if scrollView.zoomScale == 1.0 {
scrollView.contentOffset.x = lastOffsetX
return
}
// zoom scale is less than 1.0, so
// allow the scroll and update lastX
lastOffsetX = scrollView.contentOffset.x
}
Here's a complete example you can try out:
class ExampleViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .systemYellow
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemTeal
return v
}()
let infoLabel: UILabel = {
let v = UILabel()
return v
}()
// we'll use this to track the current content X offset
var lastOffsetX: CGFloat = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
scrollView.addSubview(contentView)
view.addSubview(scrollView)
view.addSubview(infoLabel)
[contentView, scrollView, infoLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
// let's add a 8 x 8 "grid" of labels to the content view
let outerVerticalStack = UIStackView()
outerVerticalStack.axis = .vertical
outerVerticalStack.spacing = 20
outerVerticalStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(outerVerticalStack)
var j: Int = 1
for _ in 1...8 {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 20
rowStack.distribution = .fillEqually
for _ in 1...8 {
let v = UILabel()
v.font = .systemFont(ofSize: 48.0, weight: .regular)
v.text = "\(j)"
v.textAlignment = .center
v.backgroundColor = .green
v.widthAnchor.constraint(equalToConstant: 240.0).isActive = true
v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
rowStack.addArrangedSubview(v)
j += 1
}
outerVerticalStack.addArrangedSubview(rowStack)
}
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -120.0),
contentView.topAnchor.constraint(equalTo: contentG.topAnchor),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
outerVerticalStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20.0),
outerVerticalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20.0),
outerVerticalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20.0),
outerVerticalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),
// put the info label below the scroll view
infoLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 20.0),
infoLabel.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
infoLabel.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
])
// we'll update min zoom in viewDidAppear
// (after all views have been laid out)
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 1.0
// we need to disable zoom bouncing, or
// we get really bad positioning effect
// when zooming in past 1.0
scrollView.bouncesZoom = false
// assign the delegate
scrollView.delegate = self
// update the info label
updateInfo()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// update min zoom scale so we can only "zoom out" until
// the content view fits the scroll view frame
if scrollView.minimumZoomScale == 1.0 {
print(contentView.frame.size)
let xScale = scrollView.frame.width / contentView.frame.width
let yScale = scrollView.frame.height / contentView.frame.height
scrollView.minimumZoomScale = min(xScale, yScale)
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// if zoom scale is 1.0
// don't allow horizontal scrolling
if scrollView.zoomScale == 1.0 && !scrollView.isZooming {
scrollView.contentOffset.x = lastOffsetX
return
}
// zoom scale is less than 1.0, so
// allow the scroll and update lastX
lastOffsetX = scrollView.contentOffset.x
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
updateInfo()
}
func updateInfo() {
let s = String(format: "%0.4f", scrollView.zoomScale)
infoLabel.text = "Zoom Scale: \(s)"
}
}

Related

iOS: How to scroll navigation bar as view controller scrolls

I want to scroll the navigation bar as the user scrolls on the view controller. This should be similar to how the YouTube app's home page is working. When the user scrolls down, the navigation bar should be made visible. The navigation bar should move as much as the scroll amount.
I'm aware of hidesBarOnSwipe and setNavigationBarHidden, but these do not give precise control of the y-axis. I'm also reading that Apple does not support directly modifying the navigation bar frame.
So, how does YouTube do this? I'm looking for an MVP demonstrating navigation bar position change along with a UIScrollView offset change.
Without additional detail about what you want to do, I'll make some guesses.
First, the top of the YouTube app's home page is almost certainly not a UINavigationBar -- it doesn't behave like one, there is no pushing/popping of controllers going on, it's in a tab bar controller setup, etc.
So, let's assume it's a view with subviews - we'll call it a "sliding header view" - and your goal is:
don't let the header view's top scroll down
"push it up" when scrolling up
"pull it down" when scrolling down
We can accomplish this by constraining the Top of that header view to the Top of the scroll view's Frame Layout Guide.
when we start to scroll, we'll save the current .contentOffset.y
when we scroll, we'll get the relative scroll y distance
if we're scrolling Up, we'll change the Top Constraint .constant value to move the header view up
if we're scrolling Down, we'll change the Top Constraint .constant value to move the header view down
Here's how it will look at the start:
as we scroll up just a little:
after we've scrolled up farther:
as we scroll down just a little:
after we've scrolled down farther:
Here's the example code for that:
Simple two-label "header" view
class SlidingHeaderView: UIView {
// simple view with two labels
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
backgroundColor = .systemBlue
let v1 = UILabel()
v1.translatesAutoresizingMaskIntoConstraints = false
v1.text = "Label 1"
v1.backgroundColor = .yellow
addSubview(v1)
let v2 = UILabel()
v2.translatesAutoresizingMaskIntoConstraints = false
v2.text = "Label 2"
v2.backgroundColor = .yellow
addSubview(v2)
NSLayoutConstraint.activate([
v1.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
v1.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
v2.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
v2.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
v1.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
v2.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
v2.topAnchor.constraint(equalTo: v1.bottomAnchor, constant: 4.0),
v2.heightAnchor.constraint(equalTo: v1.heightAnchor),
])
}
}
example view controller
class SlidingHeaderViewController: UIViewController {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.contentInsetAdjustmentBehavior = .never
return v
}()
let slidingHeaderView: SlidingHeaderView = {
let v = SlidingHeaderView()
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemYellow
return v
}()
// Top constraint for the slidingHeaderView
var slidingViewTopC: NSLayoutConstraint!
// to track the scroll activity
var curScrollY: CGFloat = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
[scrollView, slidingHeaderView, contentView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
// add contentView and slidingHeaderView to the scroll view
[contentView, slidingHeaderView].forEach { v in
scrollView.addSubview(v)
}
// add scroll view to self.view
view.addSubview(scrollView)
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
// we're going to change slidingHeaderView's Top constraint relative to the Top of the scroll view FRAME
slidingViewTopC = slidingHeaderView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0)
NSLayoutConstraint.activate([
// scroll view Top to view Top
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 0.0),
// scroll view Leading/Trailing/Bottom to safe area
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// constrain slidingHeaderView Top to scroll view's FRAME
slidingViewTopC,
// slidingHeaderView to Leading/Trailing of scroll view FRAME
slidingHeaderView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
slidingHeaderView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
// no Height or Bottom constraint for slidingHeaderView
// content view Top/Leading/Trailing/Bottom to scroll view's CONTENT GUIDE
contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// content view Width to scroll view's FRAME
contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
])
// add some content to the content view so we have something to scroll
addSomeContent()
// because we're going to track the scroll offset
scrollView.delegate = self
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if slidingHeaderView.frame.height == 0 {
// get the size of the slidingHeaderView
let sz = slidingHeaderView.systemLayoutSizeFitting(CGSize(width: scrollView.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
// use its Height for the scroll view's Top contentInset
scrollView.contentInset = UIEdgeInsets(top: sz.height, left: 0, bottom: 0, right: 0)
}
}
func addSomeContent() {
// create a vertical stack view with a bunch of labels
// and add it to our content view so we have something to scroll
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 32
stack.backgroundColor = .gray
stack.translatesAutoresizingMaskIntoConstraints = false
for i in 1...20 {
let v = UILabel()
v.text = "Label \(i)"
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.heightAnchor.constraint(equalToConstant: 48.0).isActive = true
stack.addArrangedSubview(v)
}
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16.0),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16.0),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16.0),
])
}
}
extension SlidingHeaderViewController: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
curScrollY = scrollView.contentOffset.y
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let diffY = scrollView.contentOffset.y - curScrollY
var newY: CGFloat = slidingViewTopC.constant - diffY
if diffY < 0 {
// we're scrolling DOWN
newY = min(newY, 0.0)
} else {
// we're scrolling UP
if scrollView.contentOffset.y <= -slidingHeaderView.frame.height {
newY = 0.0
} else {
newY = max(-slidingHeaderView.frame.height, newY)
}
}
// update slidingHeaderView Top constraint constant
slidingViewTopC.constant = newY
curScrollY = scrollView.contentOffset.y
}
}
Everything is done via code - no #IBOutlet or #IBAction connections needed.

UIScrollView – Adjust User Scroll Amount Akin to Scrubbing?

I have UIScrollview that the user scrolls up and down.
Is there a way to adjust how much the user's drag of the finger results in the final scroll amount?
I was looking at UIScrollview delegate methods, but haven't found a place to alter that.
scrollViewDidScroll(_:) seems too late since this is AFTER the event.
iOS users are very familiar with using scrolling views, so changing the "scroll speed" may be confusing. However, it's your app :)
Give this a try...
When the user Begins dragging, we'll grab the .contentOffset.y as a "starting point." In scrollViewDidScroll, we'll get the difference between the new .contentOffset.y and the startingY ... multiply that by the speed factor ... and then change the .contentOffset.y.
Note that manually setting .contentOffset.y triggers scrollViewDidScroll, so we'll also need to set a bool flag to prevent recursion:
class SlowScrollVC: UIViewController, UIScrollViewDelegate {
// scrollSpeed --- example values
// 1.0 == normal
// 1.5 == fast
// 0.5 == slow
var scrollSpeed: CGFloat = 0.5
var startingOffsetY: CGFloat = 0
var bManualOffset: Bool = false
let scrollView = UIScrollView()
override func viewDidLoad() {
super.viewDidLoad()
// add a bunch of labels and buttons so we have something to scroll
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 40
for i in 1...20 {
let v = UILabel()
v.text = "Label \(i)"
v.textAlignment = .center
v.backgroundColor = .cyan
stack.addArrangedSubview(v)
let b = UIButton()
b.setTitle("Button \(i)", for: [])
b.setTitleColor(.white, for: .normal)
b.setTitleColor(.lightGray, for: .highlighted)
b.backgroundColor = .systemBlue
b.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
stack.addArrangedSubview(b)
}
stack.translatesAutoresizingMaskIntoConstraints = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stack)
view.addSubview(scrollView)
// so we can see the scroll view frame
scrollView.backgroundColor = .yellow
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
stack.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
stack.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
stack.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
stack.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
stack.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
])
scrollView.delegate = self
// you may also want to adjust .decelerationRate
// try various values to see the result
//scrollView.decelerationRate = UIScrollView.DecelerationRate(rawValue: 0.99)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
startingOffsetY = scrollView.contentOffset.y
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !bManualOffset {
// get the difference between previous offset.y and new offset.y
let diff = scrollView.contentOffset.y - startingOffsetY
// adjust by scroll-speed factor
let newY = startingOffsetY + diff * scrollSpeed
// prevent recursion
bManualOffset = true
// set adjusted offset.y
scrollView.contentOffset.y = newY
// update start Y
startingOffsetY = newY
}
bManualOffset = false
}
#objc func btnTap(_ sender: UIButton) {
// just to confirm we tapped a button
print("Tap:", sender.currentTitle)
}
}

(Swift 5) UIScrollView scrolls but none of the content scrolls (video included)

I'm trying to learn to build views without storyboard. I tried to build a scrollview. On that scrollview is a UISearchBar, a UIImageView with an image and a UILabel. It works but none of the content moves. The content is all just frozen in place like no matter how far I scroll the search bar will always be on top of the page. and the image on the bottom. I've attached a video to show what I mean. There's also a problem because none of the content is where I want it to be but that's another problem. I realize this is probably because I don't know enough about constraints and autolayout and building views without storyboards.
Here's the video
class HomePageViewController: UIViewController {
var searchedText: String = ""
let label = UILabel()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = "Where are you going?"
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.barTintColor = .systemCyan
searchBar.searchTextField.backgroundColor = .white
searchBar.layer.cornerRadius = 5
return searchBar
}()
let homeImage: UIImageView = {
let homeImage = UIImageView()
homeImage.translatesAutoresizingMaskIntoConstraints = false
homeImage.clipsToBounds = true
return homeImage
}()
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.backgroundColor = .systemMint
scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 30)
return scrollView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
// setupLayout()
// tried this here doesn't do anything for me
}
func setupLayout() {
view.addSubview(scrollView)
self.scrollView.addSubview(searchBar)
homeImage.image = UIImage(named: "Treehouse")
self.scrollView.addSubview(homeImage)
label.text = "Inspiration for your next trip..."
self.scrollView.addSubview(label)
// not sure where this label is being added I want it to be underneath the image but it isn't t
let safeG = view.safeAreaLayoutGuide
let viewFrame = view.bounds
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: -10),
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor),
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
searchBar.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 50.0),
searchBar.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 0.9),
searchBar.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
homeImage.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 150),
homeImage.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 1.1),
homeImage.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
homeImage.heightAnchor.constraint(equalToConstant: viewFrame.height/2),
label.topAnchor.constraint(equalTo: homeImage.bottomAnchor, constant: 100)
])
// was doing all this in viewDidLayoutSubviews but not sure if this is better place for it
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
setupLayout()
// tried this in viewDidLoad() and it didn't solve it.
}
}
any help would be appreciated
First, when constraining subviews in a UIScrollView, you should constrain them to the scroll view's Content Layout Guide. You're constraining them to the view's safe area layout guide, so they're never going to go anywhere.
Second, it's difficult to center subviews in a scroll view, because the scroll view can scroll both horizontally and vertically. So it doesn't really have a "center."
You can either put subviews in a stack view, or, quite common, use a UIView as a "content" view to hold the subviews. If you constrain that content view's Width to the scroll view's Frame Layout Guide width, you can then horizontally center the subviews.
Third, it can be very helpful to comment your constraints, so you know exactly what you expect them to do.
Here's a modified version of your posted code:
class HomePageViewController: UIViewController {
var searchedText: String = ""
let label: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = "Where are you going?"
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.barTintColor = .systemCyan
searchBar.searchTextField.backgroundColor = .white
searchBar.layer.cornerRadius = 5
return searchBar
}()
let homeImage: UIImageView = {
let homeImage = UIImageView()
homeImage.translatesAutoresizingMaskIntoConstraints = false
homeImage.clipsToBounds = true
return homeImage
}()
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.backgroundColor = .systemMint
// don't do this
//scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 30)
return scrollView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
setupLayout()
}
func setupLayout() {
view.addSubview(scrollView)
//homeImage.image = UIImage(named: "Treehouse")
homeImage.image = UIImage(named: "natureBKG")
label.text = "Inspiration for your next trip..."
// let's use a UIView to hold the "scroll content"
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
// give it a green background so we can see it
contentView.backgroundColor = .green
contentView.addSubview(searchBar)
contentView.addSubview(homeImage)
contentView.addSubview(label)
scrollView.addSubview(contentView)
let safeG = view.safeAreaLayoutGuide
let svContentG = scrollView.contentLayoutGuide
let svFrameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scrollView to all 4 sides of view
// (generally, constrain to safe-area, but this is what you had)
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor),
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// constrain contentView to all 4 sides of scroll view's Content Layout Guide
contentView.topAnchor.constraint(equalTo: svContentG.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: svContentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: svContentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: svContentG.bottomAnchor, constant: 0.0),
// constrain contentView Width equal to scroll view's Frame Layout Guide Width
contentView.widthAnchor.constraint(equalTo: svFrameG.widthAnchor),
// constrain searchBar Top to contentView Top + 50
searchBar.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 50.0),
// constrain searchBar Width to 90% of contentView Width
searchBar.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.9),
// constrain searchBar centerX to contentView centerX
searchBar.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain homeImage Top to searchBar Bottom + 40
homeImage.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 40.0),
// constrain homeImage Width equal to contentView Width
homeImage.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0),
// constrain homeImage centerX to contentView centerX
homeImage.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain homeImage Height to 1/2 of scroll view frame Height
homeImage.heightAnchor.constraint(equalTo: svFrameG.heightAnchor, multiplier: 0.5),
// you probably won't get vertical scrolling yet, so increase the vertical space
// between the homeImage and the label by changing the constant
// from 100 to maybe 400
// constrain label Top to homeImage Bottom + 100
label.topAnchor.constraint(equalTo: homeImage.bottomAnchor, constant: 100.0),
// constrain label centerX to contentView centerX
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain label Bottom to contentView Bottom - 20
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),
])
}
}

Zoom and scroll ImageView inside the ScrollView

The screen has an aimView centered. I need to correct the ScrollView:
After zoom - the image should be centered horizontally / vertically
if there are distances from the imageView to the edges of the screen
After the zoom, it should be possible to scroll the ScrollView so
that any part of the imageView can get under the aimView
When opening the screen, the zoom was set so that the image took up the
maximum possible area
now it looks like this:
class ScrollViewController: UIViewController, UIScrollViewDelegate {
var scrollView: UIScrollView!
var imageView: UIImageView!
var image: UIImage!
var aimView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
scrollView = UIScrollView()
scrollView.delegate = self
setupScrollView()
image = #imageLiteral(resourceName: "apple")
imageView = UIImageView(image: image)
setupImageView()
aimView = UIView()
setupAimView()
}
func setupScrollView() {
scrollView.backgroundColor = .yellow
view.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
scrollView.maximumZoomScale = 10
scrollView.minimumZoomScale = 0.1
scrollView.zoomScale = 1.0
}
func setupImageView() {
imageView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: image.size.width),
imageView.heightAnchor.constraint(equalToConstant: image.size.height),
imageView.topAnchor.constraint(equalTo: scrollView.topAnchor),
imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor)
])
}
func setupAimView() {
aimView.translatesAutoresizingMaskIntoConstraints = false
aimView.backgroundColor = .green
aimView.alpha = 0.7
aimView.isUserInteractionEnabled = false
view.addSubview(aimView)
NSLayoutConstraint.activate([
aimView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
aimView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100),
aimView.widthAnchor.constraint(equalTo: aimView.heightAnchor),
aimView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
}
There are a few ways to approach this... one way:
use a UIView as the scroll view's "content"
constrain that "content" view on all 4 sides to the scroll view's content layout guide
embed the imageView in that "content" view
constrain the Top and Leading of the imageView so it will appear at the bottom-right corner of the "aim" view, when the content view is scrolled to 0,0
constrain the Trailing and Bottom of the imageView so it will appear at the top-left corner of the "aim" view, when the content view is scrolled to its max x and y
To give you an idea...
The dashed-outline rect is the scroll view frame. The green rect is the "aim" view. The yellow rect is the "content" view.
We won't be able to use the scroll view's built-in zooming, because it would also "zoom" the space between the image view's edges and the content view. Instead, we can add a UIPinchGestureRecognizer to the scroll view. When the user pinches to zoom, we'll take the gesture's .scale value and use that to change the width and height constants of the imageView. Since we've constrained that imageView to the content view, the content view will grow / shrink without changing the spacing on the sides.
Here is an example implementation (it requires an asset image named "apple"):
class PinchScroller: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
self.addGestureRecognizer(pinchGesture)
}
var scaleStartCallback: (()->())?
var scaleChangeCallback: ((CGFloat)->())?
// assuming minimum scale of 1.0
var minScale: CGFloat = 1.0
// assuming maximum scale of 5.0
var maxScale: CGFloat = 5.0
private var curScale: CGFloat = 1.0
#objc private func handlePinchGesture(_ gesture:UIPinchGestureRecognizer) {
if gesture.state == .began {
// inform controller scaling started
scaleStartCallback?()
}
if gesture.state == .changed {
// inform controller the scale changed
let val: CGFloat = gesture.scale - 1.0
let scale = min(maxScale, max(minScale, curScale + val))
scaleChangeCallback?(scale)
}
if gesture.state == .ended {
// update current scale value
let val: CGFloat = gesture.scale - 1.0
curScale = min(maxScale, max(minScale, curScale + val))
}
}
}
class AimViewController: UIViewController {
var scrollView: PinchScroller!
var imageView: UIImageView!
var contentView: UIView!
var aimView: UIView!
var imageViewTopConstraint: NSLayoutConstraint!
var imageViewLeadingConstraint: NSLayoutConstraint!
var imageViewTrailingConstraint: NSLayoutConstraint!
var imageViewBottomConstraint: NSLayoutConstraint!
var imageViewWidthConstraint: NSLayoutConstraint!
var imageViewHeightConstraint: NSLayoutConstraint!
var imageViewWidthFactor: CGFloat = 1.0
var imageViewHeightFactor: CGFloat = 1.0
override func viewDidLoad() {
super.viewDidLoad()
// make sure we can load the image
guard let img = UIImage(named: "apple") else {
fatalError("Could not load image!!!")
}
scrollView = PinchScroller()
imageView = UIImageView()
contentView = UIView()
aimView = UIView()
[scrollView, imageView, contentView, aimView].forEach {
$0?.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(imageView)
scrollView.addSubview(aimView)
// init image view width constraint
imageViewWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: 0.0)
imageViewHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: 0.0)
// to handle non-1:1 ratio images
if img.size.width > img.size.height {
imageViewHeightFactor = img.size.height / img.size.width
} else {
imageViewWidthFactor = img.size.width / img.size.height
}
// init image view Top / Leading / Trailing / Bottom constraints
imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0.0)
imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0.0)
imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0.0)
imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0.0)
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scroll view to all 4 sides of safe area
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 0.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// constrain "content" view to all 4 sides of scroll view's content layout guide
contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// activate these constraints
imageViewTopConstraint,
imageViewLeadingConstraint,
imageViewTrailingConstraint,
imageViewBottomConstraint,
imageViewWidthConstraint,
imageViewHeightConstraint,
// "aim" view: 200x200, centered in scroll view frame
aimView.widthAnchor.constraint(equalToConstant: 200.0),
aimView.heightAnchor.constraint(equalTo: aimView.widthAnchor),
aimView.centerXAnchor.constraint(equalTo: frameG.centerXAnchor),
aimView.centerYAnchor.constraint(equalTo: frameG.centerYAnchor),
])
// set the image
imageView.image = img
// disable interaction for "aim" view
aimView.isUserInteractionEnabled = false
// aim view translucent background color
aimView.backgroundColor = UIColor.green.withAlphaComponent(0.25)
// probably don't want scroll bouncing
scrollView.bounces = false
// set the scaling callback closures
scrollView.scaleStartCallback = { [weak self] in
guard let self = self else {
return
}
self.didStartScale()
}
scrollView.scaleChangeCallback = { [weak self] v in
guard let self = self else {
return
}
self.didChangeScale(v)
}
contentView.backgroundColor = .yellow
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// set constraint constants here, after all view have been initialized
let aimSize: CGSize = aimView.frame.size
imageViewWidthConstraint.constant = aimSize.width * imageViewWidthFactor
imageViewHeightConstraint.constant = aimSize.height * imageViewHeightFactor
let w = (scrollView.frame.width - aimSize.width) * 0.5 + aimSize.width
let h = (scrollView.frame.height - aimSize.height) * 0.5 + aimSize.height
imageViewTopConstraint.constant = h
imageViewLeadingConstraint.constant = w
imageViewTrailingConstraint.constant = -w
imageViewBottomConstraint.constant = -h
DispatchQueue.main.async {
// center the content in the scroll view
let xOffset = aimSize.width - ((aimSize.width - self.imageView.frame.width) * 0.5)
let yOffset = aimSize.height - ((aimSize.height - self.imageView.frame.height) * 0.5)
self.scrollView.contentOffset = CGPoint(x: xOffset, y: yOffset)
}
}
private var startContentOffset: CGPoint = .zero
private var startSize: CGSize = .zero
func didStartScale() -> Void {
startContentOffset = scrollView.contentOffset
startSize = imageView.frame.size
}
func didChangeScale(_ scale: CGFloat) -> Void {
// all sizing is based on the "aim" view
let aimSize: CGSize = aimView.frame.size
// starting scroll offset
var cOffset = startContentOffset
// starting image view width and height
let w = startSize.width
let h = startSize.height
// new image view width and height
let newW = aimSize.width * scale * imageViewWidthFactor
let newH = aimSize.height * scale * imageViewHeightFactor
// change image view width based on pinch scaling
imageViewWidthConstraint.constant = newW
imageViewHeightConstraint.constant = newH
// adjust content offset so image view zooms from its center
let xDiff = (newW - w) * 0.5
let yDiff = (newH - h) * 0.5
cOffset.x += xDiff
cOffset.y += yDiff
// update scroll offset
scrollView.contentOffset = cOffset
}
}
Give that a try. If it comes close to what you're going for, then you've got a place to start.
Edit
After playing around a bit more with scrollView.contentInset, this is a much simpler approach. It uses the standard UIScrollView with its zoom/pan functionality, and doesn't require any extra "zoom" calculations or constraint changes:
class AimInsetsViewController: UIViewController {
var scrollView: UIScrollView!
var imageView: UIImageView!
var aimView: UIView!
var imageViewTopConstraint: NSLayoutConstraint!
var imageViewLeadingConstraint: NSLayoutConstraint!
var imageViewTrailingConstraint: NSLayoutConstraint!
var imageViewBottomConstraint: NSLayoutConstraint!
var imageViewWidthConstraint: NSLayoutConstraint!
var imageViewHeightConstraint: NSLayoutConstraint!
var imageViewWidthFactor: CGFloat = 1.0
var imageViewHeightFactor: CGFloat = 1.0
override func viewDidLoad() {
super.viewDidLoad()
var imageName: String = ""
imageName = "apple"
// testing different sized images
//imageName = "apple228x346"
//imageName = "zoom640x360"
// make sure we can load the image
guard let img = UIImage(named: imageName) else {
fatalError("Could not load image!!!")
}
scrollView = UIScrollView()
imageView = UIImageView()
aimView = UIView()
[scrollView, imageView, aimView].forEach {
$0?.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(scrollView)
scrollView.addSubview(imageView)
scrollView.addSubview(aimView)
// init image view width constraint
imageViewWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: 0.0)
imageViewHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: 0.0)
// to handle non-1:1 ratio images
if img.size.width > img.size.height {
imageViewHeightFactor = img.size.height / img.size.width
} else {
imageViewWidthFactor = img.size.width / img.size.height
}
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scroll view to all 4 sides of safe area
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 0.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// constrain "content" view to all 4 sides of scroll view's content layout guide
imageView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
imageView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
imageView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
imageView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
imageViewWidthConstraint,
imageViewHeightConstraint,
// "aim" view: 200x200, centered in scroll view frame
aimView.widthAnchor.constraint(equalToConstant: 200.0),
aimView.heightAnchor.constraint(equalTo: aimView.widthAnchor),
aimView.centerXAnchor.constraint(equalTo: frameG.centerXAnchor),
aimView.centerYAnchor.constraint(equalTo: frameG.centerYAnchor),
])
// set the image
imageView.image = img
// disable interaction for "aim" view
aimView.isUserInteractionEnabled = false
// aim view translucent background color
aimView.backgroundColor = UIColor.green.withAlphaComponent(0.25)
// probably don't want scroll bouncing
scrollView.bounces = false
// delegate
scrollView.delegate = self
// set max zoom scale
scrollView.maximumZoomScale = 10.0
// set min zoom scale to less than 1.0
// if you want to allow image view smaller than aim view
scrollView.minimumZoomScale = 1.0
// scroll view background
scrollView.backgroundColor = .yellow
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// set constraint constants, scroll view insets and initial content offset here,
// after all view have been initialized
let aimSize: CGSize = aimView.frame.size
// aspect-fit image view to aim view
imageViewWidthConstraint.constant = aimSize.width * imageViewWidthFactor
imageViewHeightConstraint.constant = aimSize.height * imageViewHeightFactor
// set content insets
let f = aimView.frame
scrollView.contentInset = .init(top: f.origin.y + f.height,
left: f.origin.x + f.width,
bottom: f.origin.y + f.height,
right: f.origin.x + f.width)
// center image view in aim view
var c = scrollView.contentOffset
c.x -= (aimSize.width - imageViewWidthConstraint.constant) * 0.5
c.y -= (aimSize.height - imageViewHeightConstraint.constant) * 0.5
scrollView.contentOffset = c
}
}
extension AimInsetsViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
I think that will be much closer to what you're going for.
The easiest way to achieve this is by using a PDFView.
Code:
import PDFKit
let pdfView = PDFView(frame: self.view.bounds)
pdfView.displayDirection = .vertical
pdfView.displayMode = .singlePage
pdfView.backgroundColor = UIColor.white
if let image = UIImage(named: "sample"),
let pdfPage = PDFPage(image: image) {
let pdfDoc = PDFDocument()
pdfDoc.insert(pdfPage, at: 0)
pdfView.document = pdfDoc
pdfView.autoScales = true
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit
}
self.view.addSubview(pdfView)
Result:
First I added padding after zoom (scrollView.contentInset)
var horizontalPadding: CGFloat { view.bounds.width / 4 }
var verticalPadding: CGFloat { view.bounds.height / 4 }
func setPadding() {
let imageViewSize = imageView.frame.size
let scrollViewSize = scrollView.bounds.size
let verticalPadding = imageViewSize.height < scrollViewSize.height
? (scrollViewSize.height - imageViewSize.height) / 2
: self.verticalPadding
let horizontalPadding = imageViewSize.width < scrollViewSize.width
? (scrollViewSize.width - imageViewSize.width) / 2
: self.horizontalPadding
let toAimViewWidthSpacing = aimView.frame.origin.x
let toAimViewHeightSpacing = aimView.frame.origin.y
scrollView.contentInset = UIEdgeInsets(
top: verticalPadding + toAimViewHeightSpacing ,
left: horizontalPadding + toAimViewWidthSpacing ,
bottom: verticalPadding + toAimViewHeightSpacing ,
right: horizontalPadding + toAimViewWidthSpacing)
}
Secondly, added delegate methods scrollViewDidZoom and scrolViewDidEndZooming
func scrollViewDidZoom(_ scrollView: UIScrollView) {
setPadding()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
scrollView.contentSize = CGSize(
width: imageView.frame.width,
height: imageView.frame.height)
}
And finaly add a method to center the image which added to the viewDidLayoutSubviews()
override func viewDidLayoutSubviews() {
centerImageInScrollView()
}
func centerImageInScrollView() {
scrollView.contentSize = CGSize( width: imageView.frame.width, height: imageView.frame.height)
let newContentOffsetX = (scrollView.contentSize.width - scrollView.frame.size.width) / 2
let newContentOffsetY = (scrollView.contentSize.height - scrollView.frame.size.height) / 2
scrollView.setContentOffset(CGPoint(x: newContentOffsetX, y: newContentOffsetY), animated: true)
}
The entire code is here!
how it looks

Dynamically find the right zoom scale to fit portion of view

I have a grid view, it's like a chess board. The hierarchy is this :
UIScrollView
-- UIView
---- [UIViews]
Here's a screenshot.
Knowing that a tile has width and height of tileSide, how can I find a way to programmatically zoom in focusing on the area with the blue border? I need basically to find the right zoomScale.
What I'm doing is this :
let centralTilesTotalWidth = tileSide * 5
zoomScale = CGFloat(centralTilesTotalWidth) / CGFloat(actualGridWidth) + 1.0
where actualGridWidth is defined as tileSide multiplied by the number of columns. What I'm obtaining is to see almost seven tiles, not the five I want to see.
Keep also present that the contentView (the brown one) has a full screen frame, like the scroll view in which it's contained.
You can do this with zoom(to rect: CGRect, animated: Bool) (Apple docs).
Get the frames of the top-left and bottom-right tiles
convert then to contentView coordinates
union the two rects
call zoom(to:...)
Here is a complete example - all via code, no #IBOutlet or #IBAction connections - so just create a new view controller and assign its custom class to GridZoomViewController:
class GridZoomViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
return v
}()
let contentView: UIView = {
let v = UIView()
return v
}()
let gridStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.distribution = .fillEqually
return v
}()
var selectedTiles: [TileView] = [TileView]()
override func viewDidLoad() {
super.viewDidLoad()
[gridStack, contentView, scrollView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
var bColor: Bool = false
// create a 9x7 grid of tile views, alternating cyan and yellow
for _ in 1...7 {
// horizontal stack view
let rowStack = UIStackView()
rowStack.translatesAutoresizingMaskIntoConstraints = false
rowStack.axis = .horizontal
rowStack.distribution = .fillEqually
for _ in 1...9 {
// create a tile view
let v = TileView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = bColor ? .cyan : .yellow
v.origColor = v.backgroundColor!
bColor.toggle()
// add a tap gesture recognizer to each tile view
let g = UITapGestureRecognizer(target: self, action: #selector(self.tileTapped(_:)))
v.addGestureRecognizer(g)
// add it to the row stack view
rowStack.addArrangedSubview(v)
}
// add row stack view to grid stack view
gridStack.addArrangedSubview(rowStack)
}
// add subviews
contentView.addSubview(gridStack)
scrollView.addSubview(contentView)
view.addSubview(scrollView)
let padding: CGFloat = 20.0
// respect safe area
let g = view.safeAreaLayoutGuide
// for scroll view content constraints
let cg = scrollView.contentLayoutGuide
// let grid width shrink if 7:9 ratio is too tall for view
let wAnchor = gridStack.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0)
wAnchor.priority = .defaultHigh
NSLayoutConstraint.activate([
// constrain scroll view to view (safe area), all 4 sides with "padding"
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: padding),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: padding),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -padding),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -padding),
// constrain content view to scroll view contentLayoutGuide, all 4 sides
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
// content view width and height equal to scroll view width and height
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0),
contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: 0.0),
// activate gridStack width anchor
wAnchor,
// gridStack height = gridStack width at 7:9 ration (7 rows, 9 columns)
gridStack.heightAnchor.constraint(equalTo: gridStack.widthAnchor, multiplier: 7.0 / 9.0),
// make sure gridStack height is less than or equal to content view height
gridStack.heightAnchor.constraint(lessThanOrEqualTo: contentView.heightAnchor),
// center gridStack in contentView
gridStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0),
gridStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0),
])
// so we can see the frames
view.backgroundColor = .blue
scrollView.backgroundColor = .orange
contentView.backgroundColor = .brown
// delegate and min/max zoom scales
scrollView.delegate = self
scrollView.minimumZoomScale = 0.25
scrollView.maximumZoomScale = 5.0
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: nil, completion: {
_ in
if self.selectedTiles.count == 2 {
// re-zoom the content on size change (such as device rotation)
self.zoomToSelected()
}
})
}
#objc
func tileTapped(_ gesture: UITapGestureRecognizer) -> Void {
// make sure it was a Tile View that sent the tap gesture
guard let tile = gesture.view as? TileView else { return }
if selectedTiles.count == 2 {
// if we already have 2 selected tiles, reset everything
reset()
} else {
// add this tile to selectedTiles
selectedTiles.append(tile)
// if it's the first one, green background, if it's the second one, red background
tile.backgroundColor = selectedTiles.count == 1 ? UIColor(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0) : .red
// if it's the second one, zoom
if selectedTiles.count == 2 {
zoomToSelected()
}
}
}
func zoomToSelected() -> Void {
// get the stack views holding tile[0] and tile[1]
guard let sv1 = selectedTiles[0].superview,
let sv2 = selectedTiles[1].superview else {
fatalError("problem getting superviews! (this shouldn't happen)")
}
// convert tile view frames to content view coordinates
let r1 = sv1.convert(selectedTiles[0].frame, to: contentView)
let r2 = sv2.convert(selectedTiles[1].frame, to: contentView)
// union the two frames to get one larger rect
let targetRect = r1.union(r2)
// zoom to that rect
scrollView.zoom(to: targetRect, animated: true)
}
func reset() -> Void {
// reset the tile views to their original colors
selectedTiles.forEach {
$0.backgroundColor = $0.origColor
}
// clear the selected tiles array
selectedTiles.removeAll()
// zoom back to full grid
scrollView.zoom(to: scrollView.bounds, animated: true)
}
}
class TileView: UIView {
var origColor: UIColor = .white
}
It will look like this to start:
The first "tile" you tap will turn green:
When you tap a second tile, it will turn red and we'll zoom in to that rectangle:
Tapping a third time will reset to starting grid.

Resources