iOS14 Widget开发踩坑(五)定位与地图的使用

前言

最近又抽出时间来看小组件的问题了,产品也是想要实现一下系统应用 地图 的小组件的样子,并且研究一下小组件定位的实现。

分析

SwiftUI使用MKMapView是使用UIViewRepresentable协议将将MKMapView转化为View来进行使用的。但是在小组件中,无法使用UIViewRepresentable,也无法直接使用MKMapViewMapKit中有Map,但是在主程序可以使用,在小组件上却根本无法加载出来。我很怀疑苹果给自己的系统程序留了后门。

struct ContentView: View {
    
    
    @State var region = MKCoordinateRegion(center:CLLocationCoordinate2D(latitude: 31.203115, longitude: 121.598637), span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01))
    var body: some View {
    
    
		Map(coordinateRegion: $region, showsUserLocation: true)
	}
}

Please add a picture description
但是经过BaiduGoogle的帮助还是找到了一些蛛丝马迹以及参考文章,他们都详细的描述了如何加载地图和调用定位。我只是在这里做一下总结。
Map View in a Widget

实现案例

地图

已经明确需要使用MKMapSnapshotter,以下是MKMapSnapshotter的说明:

extension MKMapSnapshotter {
    
    
    public typealias CompletionHandler = (MKMapSnapshotter.Snapshot?, Error?) -> Void
}
@available(iOS 7.0, *)
open class MKMapSnapshotter : NSObject {
    
    
    public init(options: MKMapSnapshotter.Options)
    open func start(completionHandler: @escaping MKMapSnapshotter.CompletionHandler) // defaults to the main queue
    open func start() async throws -> MKMapSnapshotter.Snapshot
    open func start(with queue: DispatchQueue, completionHandler: @escaping MKMapSnapshotter.CompletionHandler)
    open func start(with queue: DispatchQueue) async throws -> MKMapSnapshotter.Snapshot
    open func cancel()
    open var isLoading: Bool {
    
     get }
}

extension MKMapSnapshotter {
    
    
    @available(iOS 7.0, *)
    open class Options : NSObject, NSCopying {
    
    
        @NSCopying open var camera: MKMapCamera
        open var mapRect: MKMapRect
        open var region: MKCoordinateRegion
        open var mapType: MKMapType
        @available(iOS 13.0, *)
        @NSCopying open var pointOfInterestFilter: MKPointOfInterestFilter?
        @available(iOS, introduced: 7.0, deprecated: 13.0, message: "Use pointOfInterestFilter")
        open var showsPointsOfInterest: Bool
        open var showsBuildings: Bool
        open var size: CGSize
        @available(iOS, introduced: 7.0, deprecated: 100000, message: "Use traitCollection.displayScale")
        open var scale: CGFloat
        @available(iOS 13.0, *)
        @NSCopying open var traitCollection: UITraitCollection
    }
}

Use options to configure the map, and use MKMapSnapshotter to take snapshots. The closure returns a UIImage and Bool

let mapImageSize = 440.0
func getMapSnapshot(region:MKCoordinateRegion, completionHandler: @escaping (UIImage, Bool) -> Void) {
    
    
    let options = MKMapSnapshotter.Options()
    options.pointOfInterestFilter = MKPointOfInterestFilter.includingAll
    options.region = region
    options.size = CGSize(width: mapImageSize, height: mapImageSize)
    options.showsBuildings = true
    let snapshot = MKMapSnapshotter(options: options)
    snapshot.start {
    
     (snapshot, error) in
        if ((error) != nil) {
    
    
            completionHandler(UIImage(), false)
        } else {
    
    
            if (snapshot?.image != nil) {
    
    
                completionHandler(snapshot!.image, true)
            } else {
    
    
                completionHandler(UIImage(), false)
            }
        }
    }
}
// 方法2,可以将用户位置图片直接绘制到截图中,也可以将图片放到视图里再显示,仅供参考
func getMapSnapshot2(region:MKCoordinateRegion, completionHandler: @escaping (Image, Bool) -> Void) {
    
    
    let options = MKMapSnapshotter.Options()
    options.pointOfInterestFilter = MKPointOfInterestFilter.includingAll
    options.region = region
    options.size = CGSize(width: mapImageSize, height: mapImageSize)
    options.showsBuildings = true
    let snapshot = MKMapSnapshotter(options: options)
    snapshot.start {
    
     (snapshot, error) in
        if ((error) != nil) {
    
    
            completionHandler(Image(""), false)
        } else {
    
    
            if (snapshot?.image != nil) {
    
    
                let snapShotImage = snapshot!.image
                if let pinImage = UIImage(named: "tkt_alltrack_startPoint") {
    
    
                    UIGraphicsBeginImageContextWithOptions(snapShotImage.size, true, snapShotImage.scale)
                    snapShotImage.draw(at: CGPoint.zero)
                    let fixedPinPoint = CGPoint(x: (options.size.width - pinImage.size.width) / 2, y: (options.size.height - pinImage.size.height) / 2)
                    pinImage.draw(at: fixedPinPoint)
                    let mapImage = UIGraphicsGetImageFromCurrentImageContext()
                    UIGraphicsEndImageContext()
                    DispatchQueue.main.async {
    
    
                        if (mapImage != nil) {
    
    
                            completionHandler(Image(uiImage: mapImage!), true)
                        } else {
    
    
                            completionHandler(Image(""), false)
                        }
                    }
                } else {
    
    
                    completionHandler(Image(""), false)
                }
            } else {
    
    
                completionHandler(Image(""), false)
            }
        }
    }
}

Call it in the getTimeLine method, where the smaller the value of MKCoordinateSpan , the larger the zoom level of the map.

    func getTimeline(in context: Context, completion: @escaping (Timeline<LocationWidgetEntry>) -> ()) {
    
    
        let currentDate = Date()
        let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
        // 这里使用了一个固定的坐标,可以通过主程序将用户坐标传递过来
        let coordinate = CLLocationCoordinate2D(latitude: 31.203115, longitude: 121.598637)
        let region = MKCoordinateRegion(center:coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
        getMapSnapshot(region: region) {
    
     image, success in
            let entry    = LocationWidgetEntry(date: currentDate,image: image, success: success)
            let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
            completion(timeline)
        }
    }

Write another simple view

struct LocationWidgetEntryView : View {
    
    
    var entry: LocationWidgetProvider.Entry
    var body: some View {
    
    
        if (entry.success) {
    
    
        	ZStack(alignment: .center) {
    
    
				Image(uiImage: entry.image)
                	.resizable()
                    .frame(width: mapImageSize, height: mapImageSize)
                    .scaledToFill()
                Image("tkt_alltrack_startPoint")
                    .resizable()
                    .frame(width: 20, height: 20)
                    .shadow(color: .gray, radius: 5)
            }
        } else {
    
    
            Text("截图失败了")
        }
    }
}

example

position

Use CLLocationManager to locate widgets, provided that permissions need to be added to the Info.plist file of widgets

When Widget Wants Location is YES, the option "During the use of App or widget" will appear in the program location permission. A corresponding prompt will also pop up when permission is required.
When the location permission of the main program is "always" or "while using the app or widget", the widget can request the location.

permissions
To determine whether a widget supports positioning, you can use the field authorizedForWidgetUpdates
authorizedForWidgetUpdates
code

var widgetLocationManager = WidgetLocationManager()

func getTimeline(in context: Context, completion: @escaping (Timeline<LocationWidgetEntry>) -> ()) {
    
    
    let currentDate = Date()
    let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
    widgetLocationManager.fetchLocation(handler: {
    
     location in
        print(location)
        let entry    = LocationWidgetEntry(date: currentDate,coordinate: location.coordinate)
        let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
        completion(timeline)
    })
}

class WidgetLocationManager: NSObject, CLLocationManagerDelegate {
    
    
    var locationManager: CLLocationManager?
    private var handler: ((CLLocation) -> Void)?

    override init() {
    
    
        super.init()
        DispatchQueue.main.async {
    
    
            self.locationManager = CLLocationManager()
            self.locationManager!.delegate = self
            if self.locationManager!.authorizationStatus == .notDetermined {
    
    
                self.locationManager!.requestWhenInUseAuthorization()
            }
        }
    }
    
    func fetchLocation(handler: @escaping (CLLocation) -> Void) {
    
    
        self.handler = handler
        self.locationManager!.requestLocation()
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    
    
        self.handler!(locations.last!)
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    
    
        print(error)
    }
}

struct LocationWidgetEntryView : View {
    
    
    var entry: LocationWidgetProvider.Entry
    var body: some View {
    
    
        VStack() {
    
    
			Text("latitude:\(entry.coordinate.latitude)")
			Text("longitude:\(entry.coordinate.longitude)")
       }
    }
}

Effect

question

At present, there are still some problems that have not been solved. If anyone knows, please contact me. We welcome everyone to discuss solutions together! ! ! ! !

Adapt to dark mode

The map of the map widget can automatically switch to dark mode when it is in dark mode, but the screenshot of MKMapSnapshotter can only have bright mode. I still don't know how to solve it.
dark mode

Resolved December 15, 2022

For MKMapSnapshotter, there are the following methods to set the dark mode

let options = MKMapSnapshotter.Options()
options.pointOfInterestFilter = MKPointOfInterestFilter.includingAll
options.region = region
options.size = size
options.showsBuildings = true
options.traitCollection = UITraitCollection(traitsFrom: [
    options.traitCollection,
    UITraitCollection(userInterfaceStyle: .dark)
])

So we can slightly modify the screenshot method so that it can receive the parameter of dark mode or not

func getMapSnapshot(region:MKCoordinateRegion, size: CGSize ,isDark:Bool ,completionHandler: @escaping (UIImage, Bool) -> Void) {
    
    
    let options = MKMapSnapshotter.Options()
    options.pointOfInterestFilter = MKPointOfInterestFilter.includingAll
    options.region = region
    options.size = size
    options.showsBuildings = true
    options.traitCollection = UITraitCollection(traitsFrom: [
        options.traitCollection,
        UITraitCollection(userInterfaceStyle: isDark ? .dark : .light)
    ])
    let snapshot = MKMapSnapshotter(options: options)
    snapshot.start {
    
     (snapshot, error) in
        if ((error) != nil) {
    
    
            completionHandler(UIImage(), false)
        } else {
    
    
            if (snapshot?.image != nil) {
    
    
                completionHandler(snapshot!.image, true)
            } else {
    
    
                completionHandler(UIImage(), false)
            }
        }
    }
}
// 但是这样写的话,调用就会变成
getMapSnapshot(region: region, size: context.displaySize, isDark: false) {
    
     lightImage, lightSuccess in
	getMapSnapshot(region: region, size: context.displaySize,isDark: true) {
    
     darkImage, darkSuccess in
		let entry = LocationWidgetEntry(date: currentDate, lightImage: lightImage, darkImage: darkImage, success: (lightSuccess && darkSuccess))
		let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
		completion(timeline)
	}
}

This is not elegant enough to write, we can wrap it with GCD .

func getMapSnapshotWithGCD(region:MKCoordinateRegion, size: CGSize ,completionHandler: @escaping (_ lightImage: UIImage, _ darkImage: UIImage, _ success: Bool) -> Void) {
    
    
    let group = DispatchGroup()
    let queue = DispatchQueue(label: "com.widget.MKMapSnapshotter")
    var lightSuccess = false
    var darkSuccess = false
    var lightImage = UIImage()
    var darkImage = UIImage()
    group.enter()
    queue.async {
    
    
        getMapSnapshot(region: region, size: size, isDark: false) {
    
     image, success in
            lightImage = image
            lightSuccess = success
            group.leave()
        }
    }
    group.enter()
    queue.async {
    
    
        getMapSnapshot(region: region, size: size, isDark: true) {
    
     image, success in
            darkImage = image
            darkSuccess = success
            group.leave()
        }
    }
    group.notify(queue: DispatchQueue.main) {
    
    
        let doubleSuccess = (lightSuccess && darkSuccess)
        completionHandler(lightImage, darkImage, doubleSuccess)
    }
}

// 在TimeLine中调用就会更优雅一些
getMapSnapshotWithGCD(region: region, size: context.displaySize) {
    
     lightImage, darkImage, success in
	let entry = LocationWidgetEntry(date: currentDate, lightImage: lightImage, darkImage: darkImage, success: success)
	let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
	completion(timeline)
}

// 视图使用环境变量进行判断
struct LocationWidgetEntryView : View {
    
    
    @Environment(\.widgetFamily) var family
    @Environment(\.colorScheme) var colorScheme
    var entry: LocationWidgetProvider.Entry
    var body: some View {
    
    
        if (family == .systemLarge) {
    
    
            if (entry.success) {
    
    
                ZStack(alignment: .center) {
    
    
                    Image(uiImage: colorScheme == .dark ? entry.darkImage : entry.lightImage)
                        .resizable()
                        .scaledToFill()
                    Ellipse()
                        .fill(Color.gray.opacity(0.8))
                        .frame(width: 15, height: 8)
                    ZStack(alignment: .top) {
    
    
                        Image("nh_annotation_bg_boy")
                            .resizable()
                            .frame(width: 72, height: 80)
                        Image("icon_upload_avatar_boy")
                            .resizable()
                            .frame(width: 60, height: 60)
                            .offset(y:6)
                    }
                    .offset(y:-40)
                }
                
            } else {
    
    
                VStack {
    
    
                    Text("截图失败了")
                }
            }
        } else  {
    
    
            if (entry.success) {
    
    
                ZStack(alignment: .center) {
    
    
                    Image(uiImage: colorScheme == .dark ? entry.darkImage : entry.lightImage)
                        .resizable()
                        .scaledToFill()
                    Image("tkt_alltrack_startPoint")
                        .resizable()
                        .frame(width: 20, height: 20)
                        .shadow(color: .gray, radius: 5)
                }
                
            } else {
    
    
                VStack {
    
    
                    Text("截图失败了")
                }
            }
        }
    }
}

After writing in this way, when switching the display mode of the system, the screenshot of the map will follow the replacement color.

Center point offset problem

MKMapSnapshotter screenshots, the user coordinates will be displayed in the center, but the designer wants to offset the user coordinates (small size down 15, right 25; medium size down 15 to right 90), but use the image to zoom in and move The method of displaying the center point of the picture is not feasible, because the medium size needs to increase the width by 180, which exceeds the limit of the screenshot, resulting in failure.

Resolved December 19, 2022

You can only calculate the latitude and longitude coordinates of the new center point to take a screenshot, and then place the pin element on the original latitude and longitude coordinates, so that you can only screenshot pictures of the size of the widget.
logic

struct LocationWidgetEntry: TimelineEntry {
    
    
    let date: Date
    let offsetX:CGFloat
    let offsetY:CGFloat
    let lightImage: UIImage
    let darkImage: UIImage
    let success:Bool
}

struct LocationWidgetProvider: TimelineProvider {
    
    
    var widgetLocationManager = WidgetLocationManager()
    let emptyEntry = LocationWidgetEntry(date: Date(),offsetX: 0,offsetY: 0,lightImage: UIImage(), darkImage: UIImage(), success: false)
    
    func placeholder(in context: Context) -> LocationWidgetEntry {
    
    
        emptyEntry
    }
    
    func getSnapshot(in context: Context, completion: @escaping (LocationWidgetEntry) -> ()) {
    
    
        completion(emptyEntry)
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<LocationWidgetEntry>) -> ()) {
    
    
        // 当前时间
        let currentDate = Date()
        // 刷新时间:15分钟后
        let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
        // 地图精细度
        let delta = 0.01
        // 纬度
        let la = 31.203115
        // 经度
        let lo = 121.598637
        // Y轴偏移量
        let offsetY = 15.0
        // X轴偏移量
        var offsetX = 25.0
        if context.family == .systemMedium {
    
    
            offsetX = 90.0
        }
        // 用户坐标
        let coordinate = CLLocationCoordinate2D(latitude:la , longitude: lo)
        // 用户区域
        let region = MKCoordinateRegion(center:coordinate, span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta))
        // 计算用地图
        let map = MKMapView(frame: CGRect(x: 0, y: 0, width: context.displaySize.width, height: context.displaySize.height))
        map.region = region
        // 用户点
        let point = map.convert(coordinate, toPointTo: map)
        // 新中心点
        let cPoint = CGPoint(x: point.x - offsetX, y: point.y - offsetY)
        // 新中心坐标
        let cCoordinate = map.convert(cPoint, toCoordinateFrom: map)
        // 新中心区域
        let cRegion = MKCoordinateRegion(center:cCoordinate, span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta))
        // 截图
        getMapSnapshotWithGCD(region: cRegion, size: context.displaySize) {
    
     lightImage, darkImage, success in
            let entry = LocationWidgetEntry(date: currentDate, offsetX: offsetX, offsetY: offsetY, lightImage: lightImage, darkImage: darkImage, success: success)
            let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
            completion(timeline)
        }
    }
}

struct LocationWidgetEntryView : View {
    
    
    @Environment(\.widgetFamily) var family
    @Environment(\.colorScheme) var colorScheme
    var entry: LocationWidgetProvider.Entry
    var body: some View {
    
    
        if (entry.success) {
    
    
            ZStack(alignment: .center) {
    
    
                Image(uiImage: colorScheme == .dark ? entry.darkImage : entry.lightImage) // 适配暗黑模式
                    .resizable()
                    .scaledToFit()
                Ellipse()
                    .fill(Color.gray.opacity(0.8))
                    .frame(width: 15, height: 8)
                    .offset(x: entry.offsetX, y: entry.offsetY) // 偏移到用户位置
                ZStack(alignment: .top) {
    
    
                    Image("nh_annotation_bg_boy")
                        .resizable()
                        .frame(width: 72, height: 80)
                    Image("icon_upload_avatar_boy")
                        .resizable()
                        .frame(width: 60, height: 60)
                        .offset(y:6)
                }
                .offset(x: entry.offsetX, y: (entry.offsetY - 40)) // 偏移到用户位置,并升高到自己的底部对齐
            }
        } else {
    
    
            VStack {
    
    
                Text("截图失败了")
            }
        }
    }
}

Please add a picture description

delay problem

Use the above code to request positioning, and the result returns with a delay of 5s-20s. I don't know the reason.

References for this article

《10 Tips on Developing iOS 14 Widgets》
《ShowingMapsInWidgets》
《SHOWING MAP PREVIEW WITH MKMAPSNAPSHOTTER》
《Fetching current location in iOS 14 Widget》
《Accessing Location Information in Widgets》

Widget Reference Summary

《Widgets》
《How to create Widgets in iOS 14 in Swift》
《Add configuration and intelligence to your widgets》
《Creating a Widget Extension》
《Keeping a Widget Up To Date》
《Making a Configurable Widget》
《SwiftUI-Text》
《Mix Edited oc calls swift"
"From a developer's point of view iOS 14 widgets"
"iOS14WidgetKit development practice 1-4"
"iOS14 Widget development related and error-prone place handling"
"iOS widget Widget stepping on the pit"
"iOS widget Widget Development from 0 to 1"
"Detailed Explanation of the Use of Swift-Realm Database"
"iOS14 WidgetKit Small Test - User Configuration and Intent"
[iOS14] Imitation of NetEase Cloud Desktop Widget (1)
[iOS14] Imitation of NetEase Cloud Desktop Widget (2) )
[iOS14] Imitation of NetEase Cloud Desktop Widget (3)
[iOS14] Imitation of NetEase Cloud Desktop Widget (4)
"iOS14 Widget Development Stepping Pit (1) Revised Version - Initial Identification and Refresh"
"iOS14 Widget Development Stepping Pit (2) ) Corrected version - multiple widgets"
"iOS14 Widget development pitfall (3) data communication and user configuration"
"IOS14 Widget Development Stepping Pit (4) Realization of Pseudo-transparency and Other Research"

Guess you like

Origin blog.csdn.net/qq_38718912/article/details/128318070