4

Showing Maps in Widgets

 3 years ago
source link: https://useyourloaf.com/blog/showing-maps-in-widgets/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

If you want to show a map view in a widget your first thought might be to use a MapKit Map view. Unfortunately WidgetKit doesn’t allow it. What you can do is create a snapshot of the map and show that.

I’m not going to cover the basics of creating a widget in this post. I’m concentrating on the generation of the map snapshot in the widget timeline provider. If you’re new to widget development see WidgetKit for iOS - Getting Started.

Showing A Map With MapKit

The MapKit framework lets you embed maps or satellite images in your App. SwiftUI added direct support in iOS 14, removing the need to use UIViewRepresentable to wrap MKMapView. Here’s a quick example of a SwiftUI view that shows some details about a country together with a map of the country:

struct CountryView: View {
  let country: Country

  var body: some View {
    VStack {
      CountryFields(country: country)
      Divider()
      Map(coordinateRegion: .constant(country.region))
    }
    .navigationBarTitle(Text(country.name), displayMode: .inline)
  }
}

There are a few ways to create the map view and configure how the user interacts with the map. In this case I created the map view with an MKCoordinateRegion that represents the borders of the country. The initializer takes a binding to a region that is updated as the user moves around the map. I don’t care about that so I’m passing a constant value:

Map(coordinateRegion: .constant(country.region))

The resulting view looks like this:

Country view including a map view

Showing Maps In A Widget

Unfortunately we can’t use the same approach for a widget. WidgetKit restricts us to a subset of SwiftUI views and Map is not allowed. Nor can you fallback to UIKit, by wrapping an MKMapView, as UIViewRepresentable is not supported with widgets.

What you can do is use MKMapSnapshotter to capture a map into an image. This is not a new API, it has existed since iOS 7, but it’s new to me so I’ll walk through the main steps.

Note: The snapshot does not include any overlays or annotations you add to the map. You’ll have to draw them on the resulting image yourself if you need them.

My timeline entry has the mandatory date, a country model and the map snapshot image:

struct MapEntry: TimelineEntry {
  let date: Date
  let country: Country
  let image: UIImage
}

We need to generate the map snapshot in the getTimeline method of the TimelineProvider. The main steps:

  1. Create an MKMapSnapshotter.Options object and configure the snapshot.
  2. Create an MKMapSnapshotter using the configured options.
  3. Start the snapshot request and handle the result in the completion handler.

Configuring The Map Snapshot

I’ve extracted the first two steps into a method that takes my country model and desired size and returns the configured MKMapSnapshotter object:

private func makeSnapshotter(for country: Country, with size: CGSize)
  -> MKMapSnapshotter {
  let options = MKMapSnapshotter.Options()
  options.region = country.region
  options.size = size

  // Force light mode snapshot
  options.traitCollection = UITraitCollection(traitsFrom: [ 
    options.traitCollection, 
    UITraitCollection(userInterfaceStyle: .light)
  ])

  return MKMapSnapshotter(options: options)
}

The most important configuration option is to set the region of the map to capture. I’m using an MKCoordinateRegion that I get from the country. You can alternatively use an MKMapRect if that’s more convenient. I’m also setting the size of the image that I want created.

You can change the type of map to capture. For example, if you want a hybrid satellite image and street map that shows building information:

options.mapType = .hybrid
options.showsBuildings = true

I’m forcing the image output to use light mode by modifying the traitCollection. Note that I’m using the UITraitCollection(traitsFrom:) method to merge the trait I want with any existing traits:

options.traitCollection = UITraitCollection(traitsFrom: [ 
  options.traitCollection, 
  UITraitCollection(userInterfaceStyle: .light)
])

Requesting The Map Snapshot

We can now build our timeline. I first load a random country. I’ll skip the details of this but the data comes from a file shared by the main app:

func getTimeline(in context: Context, 
  completion: @escaping (Timeline<MapEntry>) -> Void) {
  let countries = loadCountries().randomSample(count: 1)
  if let country = countries.first {

We can then create our map snapshotter configured with the map region for the country and the display size of the widget:

    let mapSnapshotter = makeSnapshotter(for: country,
      with: context.displaySize)

Finally we start the snapshot request and build the timeline entry in the completion handler:

    mapSnapshotter.start { (snapshot, error) in
      if let snapshot = snapshot {
        let date = Date()
        let nextUpdate = Calendar.current.date(byAdding: .hour, 
          value: 1,
          to: date)!
        let entry = MapEntry(date: date, 
          country: country,
          image: snapshot.image)
        let timeline = Timeline(entries: [entry], 
          policy: .after(nextUpdate))
        completion(timeline)
      }
    }
  }
}

If we get back a snapshot object we use the image it contains to build our MapEntry and then call the timeline provider completion handler with a policy to update after 1 hour.

To complete the picture my map widget entry view that shows the image:

struct MapWidgetEntryView : View {
  var entry: MapEntry
  var body: some View {
    Image(uiImage: entry.image)
      .widgetURL(entry.country.url)
  }
}

The final appearance of the widget at medium size:

Map widget in medium size

Read More


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK