iOS14 Widget开发踩坑(五)定位与地图的使用
前言
最近又抽出时间来看小组件的问题了,产品也是想要实现一下系统应用 地图 的小组件的样子,并且研究一下小组件定位的实现。
分析
SwiftUI使用MKMapView是使用UIViewRepresentable协议将将MKMapView转化为View来进行使用的。但是在小组件中,无法使用UIViewRepresentable,也无法直接使用MKMapView,MapKit中有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)
}
}
但是经过Baidu和Google的帮助还是找到了一些蛛丝马迹以及参考文章,他们都详细的描述了如何加载地图和调用定位。我只是在这里做一下总结。
实现案例
地图
已经明确需要使用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("截图失败了")
}
}
}
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.
To determine whether a widget supports positioning, you can use the field 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)")
}
}
}
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.
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.
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("截图失败了")
}
}
}
}
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"