0

Swift 5 / Xcode 12.4

I've got a single png image that's downloaded into the Documents folder and then loaded at runtime (currently as UIImage). This image has to act as some type of map:

  1. Pinch zoom
  2. Pan
  3. I want to place some type of map marker (e.g. a dot) in specific spots: The user can click on them (to open a popup with more information) and they move according to the zoom/pan but always stay the same size.
  4. Not full screen but inside a specific area in my ViewController.

I already did the same thing in Android but all Java map libraries I found require tiles (I've only got a single big image), so I ended up using a "zoom/pan" library (also lets you set the maximum zoom) and created my own invisible image sublayer for the markers.

For iOS I've found the Goggle Maps SDK and the Apple MapKit so far but they both look like they load rl map data and you can't set the actual image - is this possible with either of them?

I haven't found a zoom/pan library for iOS yet (at least one that's not 5+ years old) either, so how do I best accomplish this? Write my own zoom/pan listeners and use some type of sublayer (that moves with the parent) for the map markers - is that the way to go/what UI objects do I have to use?

Neph
  • 1,823
  • 2
  • 31
  • 69

3 Answers3

0

this will help with the pinch to zoom - https://stackoverflow.com/a/58558272/2481602

this will help you to achieve a pan - How do I pan the image inside a UIImageView?

As far as the imposed markers, that you will have to manually handle the transformations and apply the the anchor points of the marker image. the documentation here - https://developer.apple.com/documentation/coregraphics/cgaffinetransform and this supplies a loose explanation - https://medium.com/weeronline/swift-transforms-5981398b437d

it not being full screen just needs a view to hold the scrollView that will hold the map in the location on the screen that you want.

Not a full answer but hopefully this will all point you in the right direction

MindBlower3
  • 485
  • 4
  • 20
  • Thanks for your answer. Zoom/pan shouldn't be too much of a problem, luckily there are a bunch of posts about that, even though I'd prefer to use a library for that of course (you don't happen to know a new-ish one?). The parts I'm not sure about: 1. What UI object to use for zooming/panning, so it still looks good/isn't laggy (my images can be around 10mb). 2. What type of sublayer/draw method to use for the map markers - it probably needs to redraw often (so size doesn't change) and shouldn't be laggy. 3. And before I start all of this: Is there a map library that doesn't work with tiles? – Neph Mar 12 '21 at 13:06
0

Use a UIScrollView for the pinch/zoom/pan. To add the markers add them to a container view atop the scroll view, and respond to scroll view changes (scrollViewDidEndZooming, scrollViewDidZoom, scrollViewDidEndDragging...) by updating the positions of the annotation views in the container - you'll need to use UIView's convert to convert between coordinate systems, setting the center of annotation views to the appropriate point converted from your scrollview to the container view. Container view should be same size as scrollview, not scrollview's content. Or you could add the annotations into the scrollview's content but then you have to update the transforms of those views to counter-magnify them as you zoom in.

Shadowrun
  • 3,572
  • 1
  • 15
  • 13
0

One approach...

  • Use "standard" scroll view zoom/pan functionality
  • Use image view as viewForZooming in the scroll view
  • add "marker views" to the scroll view as siblings of the image view (not subviews of the image view)

For the marker positions, calculate the percent location. So, for example, if your image is 1000x1000, and you want a marker at 100,200, that marker would have a "point percentage" of 0.1,0.2. When the image view is zoomed, change the frame origin of the marker by its location percentages.

Here is a complete example (done very quickly, so just to get you going)...

I used this 1600 x 1600 image, with marker locations:

enter image description here

A simple "marker view" class:

class MarkerView: UILabel {
    var yPCT: CGFloat = 0
    var xPCT: CGFloat = 0
}

And the controller class:

class ZoomWithMarkersViewController: UIViewController, UIScrollViewDelegate {
    
    let imgView: UIImageView = {
        let v = UIImageView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()
    
    let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()
    
    var points: [CGPoint] = [
        CGPoint(x:  200, y:  200),
        CGPoint(x:  800, y:  300),
        CGPoint(x:  500, y:  700),
        CGPoint(x: 1100, y:  900),
        CGPoint(x:  300, y: 1200),
        CGPoint(x: 1300, y: 1400),
    ]
    
    var markers: [MarkerView] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // make sure we have an image
        guard let img = UIImage(named: "points1600x1600") else {
            fatalError("Could not load image!!!!")
        }
        
        // set the image
        imgView.image = img
        
        // add the image view to the scroll view
        scrollView.addSubview(imgView)
        
        // add scroll view to view
        view.addSubview(scrollView)
        
        // respect safe area
        let safeG = view.safeAreaLayoutGuide
        
        // to save on typing
        let contentG = scrollView.contentLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // scroll view inset 20-pts on each side
            scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
            scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
            
            // square (1:1 ratio)
            scrollView.heightAnchor.constraint(equalTo: scrollView.widthAnchor),
            
            // center vertically
            scrollView.centerYAnchor.constraint(equalTo: safeG.centerYAnchor),
            
            // constrain all 4 sides of image view to scroll view's Content Layout Guide
            imgView.topAnchor.constraint(equalTo: contentG.topAnchor),
            imgView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
            imgView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
            imgView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
            
            // we will want zoom scale of 1 to show the "native size" of the image
            imgView.widthAnchor.constraint(equalToConstant: img.size.width),
            imgView.heightAnchor.constraint(equalToConstant: img.size.height),
            
        ])
        
        // create marker views and
        //  add them as subviews of the scroll view
        //  add them to our array of marker views
        var i: Int = 0
        points.forEach { pt in
            i += 1
            let v = MarkerView()
            v.textAlignment = .center
            v.font = .systemFont(ofSize: 12.0)
            v.text = "\(i)"
            v.backgroundColor = UIColor.green.withAlphaComponent(0.5)
            scrollView.addSubview(v)
            markers.append(v)
            v.yPCT = pt.y / img.size.height
            v.xPCT = pt.x / img.size.width
            v.frame = CGRect(origin: .zero, size: CGSize(width: 30.0, height: 30.0))
        }
        
        // assign scroll view's delegate
        scrollView.delegate = self
        
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        print(#function)
        
        guard let img = imgView.image else { return }
        
        // max scale is 1.0 (original image size)
        scrollView.maximumZoomScale = 1.0
        
        // min scale fits the image in the scroll view frame
        scrollView.minimumZoomScale = scrollView.frame.width / img.size.width
        
        // start at min scale (so full image is visible)
        scrollView.zoomScale = scrollView.minimumZoomScale
        
        // just to make the markers "appear" nicely
        markers.forEach { v in
            v.center = CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY)
            v.alpha = 0.0
        }

    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // animate the markers into position
        UIView.animate(withDuration: 1.0, animations: {
            self.markers.forEach { v in
                v.alpha = 1.0
            }
            self.updateMarkers()
        })
        
    }

    func updateMarkers() -> Void {
        markers.forEach { v in
            let x = imgView.frame.origin.x + v.xPCT * imgView.frame.width
            let y = imgView.frame.origin.y + v.yPCT * imgView.frame.height
            
            // for example:
            //  put bottom-left corner of marker at coordinates
            v.frame.origin = CGPoint(x: x, y: y - v.frame.height)
            
            // or
            //  put center of marker at coordinates
            //v.center = CGPoint(x: x, y: y)
        }
    }
    
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateMarkers()
    }
    
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imgView
    }
    
}

I'm placing the markers so their bottom-left corner is at the marker-point.

It starts like this:

enter image description here

and looks like this after zooming-in on marker #3:

enter image description here

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks for your long answer! It's going to take me a bit to test/finish this, so sorry in advance if I don't upvote,... right now. A couple of questions: 1. Usually I set constrains in the xb, not in code, so it's just a general question: I thought all the "NS" stuff is deprecated. Is this not the case here? 2. I see that you're not using any gesture recognizers. I know that a `ScrollView` can, well, scroll but does it really also provide a zoom function out of the box?! – Neph Mar 15 '21 at 10:54
  • 3. It's good to know that you can use just any view, which might make this a bit easier (in the Android version I actually draw on a canvas). I need the markers to be filled circles (middle=set coordinates) with text (=`UILabel`) next to them, everything in a specific color. What would you recommend for drawing the circles? Or should I just use one of the symbols that come with Xcode (like the smilies, icons,... - open the list with cmd+ctrl+space), e.g. "black circle" or "medium black circle"? – Neph Mar 15 '21 at 11:08
  • @Neph - 1. I'm assuming you'll be adding "markers" dynamically, so doing things via code is a natural path. You can still layout your scrollView (and anything else) in IB. But, no, "NS" stuff is not deprecated. 2. Yes, scrollViews have "built-in" zoom functionality. 3. Yes, you can use anything as a "marker view" ... a view with imageView(s), label(s), custom views, etc as subviews. – DonMag Mar 15 '21 at 12:50
  • 1. Yes and no. My app downloads most information from a server at runtime and the first image might have only 1 marker, while the second has 10 (that's why drawing shouldn't be too resource intensive). One the ViewController/the image is loaded, no markers will be added or removed, not by the app or by the user. – Neph Mar 15 '21 at 15:25
  • I just tried to set up the layout in XIB the way you did: Constraining the imgView to the "ContentLayoutGuide" (instead of the ScrollView), 20pts leading/trailing, 1:1 ratio and also 10pts top (instead of "center vertically"). It looks okay in the simulator but XIB complains about missing constraints for the ScrollView for x/z position. If I let it add them automatically, it complaints about conflicts. If I use the ScrollView instead of the guide, it displays the usual "Content Size Ambiguity" error. – Neph Mar 15 '21 at 16:02
  • @Neph - what other UI elements do you have that you need to use a XIB? – DonMag Mar 15 '21 at 17:15
  • There's also a 2nd view with labels at the bottom of the page and I might add a couple more things but I won't do that until this works. I like the XIB because it instantly displays the result and tells me if something's wrong, without having to build it first. Plus, let's be honest, it's easier to see what works and what doesn't. I just temporarily added your "center vertically" constraint for the ScrollView, removed the top constraint and the extra view but still get the same error. Unless the 5th paragraph changes it, your layout probably gives the error too but you just don't see it. – Neph Mar 16 '21 at 09:31
  • Question about the "zoom scale" in your code: As I said, I set all of the constraints through XIB, so I can't set the imgView->img ones (last two in your code). With the zoom scale you set in `viewDidLayoutSubviews` my image is quite small in the beginning (about one third of the screen width) and it gets bigger to eventually fill the whole width when I zoom in. If I set the maximum to e.g. 5 and the minimum to 1.0, the image is too big to fit the screen and scrolling is enabled. How do I do what you do with those constraints but through XIB, so without setting constraints in code? – Neph Mar 16 '21 at 12:03
  • @Neph - there are some things that are problematic when using IB, and really need to be done in code. Can you put a minimal example of your project up on GitHub (or somewhere else) so I can see what you're trying to do? – DonMag Mar 16 '21 at 12:20
  • You can find the code for that ViewController [here](https://pastebin.com/RbdTWLPt) and the layout [here](https://stackoverflow.com/q/66653976/2016165). I've been testing the app mostly on my iPad Pro 9.7 (2016) and just ran it on the iPhones SE (1st gen) simulator again: There the image fills the whole width but on the iPad it doesn't. I gave the ScrollView a red background and it's fine on both. On both devices its width is 320.0pts. On the iPhone the surrounding parent view is also 320 but on the iPad it's 954. Looks like this is probably caused by the ScrollView's constraint problem. – Neph Mar 16 '21 at 13:11
  • @Neph - I gave you an answer to "auto-layout error/warning" in IB on your other question. As to your imageView size and scaling, if the code example I provided doesn't make sense (or isn't working for you), you'll need to put a *working* project up -- including the "mymap.png" image you're trying to use. – DonMag Mar 16 '21 at 13:33
  • I tested your suggestion on the other question: It gets rid of the warnings but unfortunately doesn't solve the problem. On my iPad the image is still quite small, even though the ScrollView still fills the whole width - it also does that now in XIB with an iPad model, which it didn't before. Sorry, I can't upload the whole project because there's a lot more to it than just this ViewController. You can use [this](https://svs.gsfc.nasa.gov/vis/a000000/a002900/a002911/frames/2048x2048/pearl-0003.png) image, it's the same size. – Neph Mar 16 '21 at 13:56
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/229985/discussion-between-donmag-and-neph). – DonMag Mar 16 '21 at 14:00
  • 1
    @Neph - if you're still having trouble figuring this out when using Storyboards, take a look at this sample project: https://github.com/DonMag/EmulateMapMarkers – DonMag Mar 16 '21 at 17:19
  • As I said in the other question, centering the ImgView inside the `Content Layout Guide` removed the warning and a minimum zoom scale of 1.0 (=default) now displays the full image on both the iPad and the simulated iPhone - no need to compute the current zoom scale anymore. The size of the scrollView is still messed up at runtime though: It says that it's only 320pts, even though it visibly fills the full width of the iPad (which is 768pts, according to the childView). – Neph Mar 17 '21 at 12:12
  • @Neph - I believe I explained that ***embedded subviews*** do not yet have their frames set in `viewDidLayoutSubviews()` ... you need to either subclass the superview and implement `layoutSubviews()` or wait until the frame has been set (as I did in the example project). However... this is well beyond the scope of your original question. – DonMag Mar 17 '21 at 12:17
  • Regarding the project: Thanks so much & thanks for the work you put into it! You set up the MarkerView completely in code, so I understand that you have to set up the constraints there too. What I still don't understand is why you set so many other constraints in code too. Isn't the point of the IB that you set everything up once, then don't touch it again? My app only supports portrait mode (sorry, didn't think it was important), so the ScrollView/the image itself aren't going to change at any time and should always use the same constraint (=fill ScrollView without losing its aspect ratio). – Neph Mar 17 '21 at 12:18
  • Ad "embedded subview": Ah, I see. What counts as "embedded"? My `ChildView` is embedded in the default `View` and yet it does get the right width in `viewDidLayoutSubviews()`. Is everything "embedded" that's a child of a view that you create yourself (hence, a grand-child of the default `View`)? – Neph Mar 17 '21 at 12:22
  • 1
    yes - `viewDidLayoutSubviews()` is called when the controller has laid-out its subviews. That doesn't mean the layout of *subviews of those subviews* has been done yet. – DonMag Mar 17 '21 at 12:26
  • As far as using IB ... the more dynamic your UI, the more you have to do in code. Laying out elements in Storyboard can only get you so far. – DonMag Mar 17 '21 at 12:27
  • I just set up a marker similar to how you did in the github project, except that I created a `MarkerView.xib`, set its class to `MarkerView.swift` and then created outlets for the dot and the name label - I uploaded the code [here](https://pastebin.com/p32DpaMx). But every time I try to set the name label's text, the app crashes because the label is "nil". I also tried to set the name in `viewDidAppear` but it still happened. That can't be too early to set it, right? – Neph Mar 18 '21 at 12:55
  • @Neph - this has gone ***way*** beyond your original question. If the example code I posted answered your question (that is, showed you how to zoom/pan an image while keeping "markers" at their original size and properly positioning them), mark it as accepted and move on. *"Why are my XIB outlets nil?"* is completely unrelated (and has been answered many times here on Stack Overflow... try a little searching). – DonMag Mar 18 '21 at 13:29
  • This still refers to the example project you posted as an extended answer to my question and it's still about the same topic: Map makers for an image that acts as a map. If I can't get the markers to even show up, I also can't test the rest. So why post the project if you don't want to answer questions about it? And yes, I googled the problem and tested multiple suggestions, which consist of "clean your project", "clear project data", "restart Xcode",... but none of it helped. – Neph Mar 18 '21 at 13:39
  • @Neph - your pastebin link - https://pastebin.com/p32DpaMx - doesn't show any code for loading / instantiating the XIB. `let marker = MarkerView()` just creates an instance of the class. You can certainly ask me questions to clarify the example code I wrote --- but, since I didn't use any XIB files, I wouldn't say *"Why are my XIB outlets nil?"* is related in any way. – DonMag Mar 18 '21 at 14:19
  • Ah, I see, I thought creating the instance would be enough already because it seems to be when you create the view in code. Thanks for checking the code! Displaying the view works now (I set the class for the view, not the "File's Owner", which caused the error) and funny enough it automatically moves to the right position when you pan the ScrollView, only with zooming the position isn't set properly (haven't added that part yet). – Neph Mar 18 '21 at 16:00