Search code examples
iosswiftuiscrollviewzooming

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.

enter image description here

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.


Solution

  • 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:

    enter image description here

    The first "tile" you tap will turn green:

    enter image description here

    When you tap a second tile, it will turn red and we'll zoom in to that rectangle:

    enter image description here

    Tapping a third time will reset to starting grid.