文章

iOS 小组件 - 创建透明小组件

iOS 小组件 - 创建透明小组件

本文记录了创建透明小组件的思路和实现 可以尝试一下我们的 万象小组件 来体验效果

何为透明小组件

用户将手机桌面截图上传到 App, App 内根据组件位置和尺寸进行截图, 用作小组件的背景, 这样放到桌面的小组件看起来就和桌面浑然一体好像透明一样.

用户操作步骤为:

  • 手机桌面截屏, 上传用于制作透明背景
  • 在 App 中保存小组件之后, 在屏幕上去添加对应小组件, 长按编辑,选择小组件当前位置即可

App 的流程

核心逻辑即: 用户上传透明背景后, App 对图片进行截取和保存. 根据小组件选择的位置获取背景

实现如下:

获取小组件尺寸信息

小组件尺寸信息, 苹果官网有详细尺寸,如:

根据指定组件类型获取准确位置信息

位置规则为: (大组件在屏幕上只能同时展示一个)

小组件中组件大组件
左上/右上
左中/右中-
左下/右下

用代码表示如下:

1
2
3
4
5
6
7
8
9
10
11
/// 组件大小
enum WidgetSize {
    case small, mudium, large
}
    
/// 组件位置
enum WidgetPosition: CaseIterable {
    case smallTopLeft, smallTopRight, smallCenterLeft, smallCenterRight, smallBottomLeft, smallBottomRight
    case mudiumTop, mudiumCenter, mudiumBottom
    case largeTop, largeBottom
}

结合准确的[小组件类型&&指定位置]获取准确 frame 并截图

获取小组件位置信息最简单的办法就是根据苹果提供的主流设备尺寸去实测组件的位置. 这虽然耗时, 但是设备每年才更新一次,所以是具备可行性的.

转换为代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
private extension WidgetSizeManager {
    
    var deviceWidgetSizeInfo: [String: [WidgetSize: String]] {
        [
            // 14 Pro Max / 15 P / 15 Pro Max
            "430×932": [
                .small: "170x170",
                .mudium: "364x170",
                .large: "364x382"
            ],
            // 12 Pro Max / 13 Pro Max / 14 Plus
            "428x926": [
                .small: "170x170",
                .mudium: "364x170",
                .large: "364x382"
            ],
            // Xs Max / XR /11 / 11 Pro Max
            "414x896": [
                .small: "169x169",
                .mudium: "360x169",
                .large: "360x379"
            ],
            // 6s P / 7P / 8P
            "414x736": [
                .small: "159x159",
                .mudium: "348x157",
                .large: "348x357"
            ],
            // 14 Pro / 15 / 15 Pro
            "393x852": [
                .small: "158x158",
                .mudium: "338x158",
                .large: "338x354"
            ],
            // 12 / 12 Pro / 13 / 13 Pro / 14
            "390x844": [
                .small: "158x158",
                .mudium: "338x158",
                .large: "338x354"
            ],
            // X / Xs / 11 Pro / 12 mini    / 13 mini
            "375x812": [
                .small: "155x155",
                .mudium: "329x155",
                .large: "329x345"
            ],
            // 6s / 7 / 8 / SE2
            "375x667": [
                .small: "148x148",
                .mudium: "321x148",
                .large: "321x324"
            ],
            // unknow
            "360x780": [
                .small: "155x155",
                .mudium: "329x155",
                .large: "329x345"
            ],
            // SE
            "320x568": [
                .small: "141x141",
                .mudium: "292x141",
                .large: "292x311"
            ]
        ]
    }
    
    var deviceWidgetPointInfo: [String: [WidgetPosition: String]] {
        [
            // 14 Pro Max / 15 P / 15 Pro Max
            "430×932": [
                .smallTopLeft: "(x:31,y:94)",
                .smallTopRight: "(x:229,y:94)",
                .smallCenterLeft: "(x:31,y:306)",
                .smallCenterRight: "(x:229,y:306)",
                .smallBottomLeft: "(x:31,y:518)",
                .smallBottomRight: "(x:229,y:518)",
                
                .mudiumTop: "(x:33,y:94)",
                .mudiumCenter: "(x:33,y:306)",
                .mudiumBottom: "(x:33,y:518)",
                
                .largeTop: "(x:33,y:94)",
                .largeBottom: "(x:33,y:306)"
                    
            ],
            // 12 Pro Max / 13 Pro Max / 14 Plus
            "428x926": [
                .smallTopLeft: "(x:32,y:82)",
                .smallTopRight: "(x:226,y:82)",
                .smallCenterLeft: "(x:32,y:294)",
                .smallCenterRight: "(x:226,y:294)",
                .smallBottomLeft: "(x:32,y:506)",
                .smallBottomRight: "(x:226,y:506)",
                
                .mudiumTop: "(x:32,y:82)",
                .mudiumCenter: "(x:32,y:294)",
                .mudiumBottom: "(x:32,y:506)",
                
                .largeTop: "(x:32,y:82)",
                .largeBottom: "(x:32,y:294)"
            ],
            // Xs Max / XR /11 / 11 Pro Max
            "414x896": [
                .smallTopLeft: "(x:27,y:76)",
                .smallTopRight: "(x:218,y:76)",
                .smallCenterLeft: "(x:27,y:286)",
                .smallCenterRight: "(x:218,y:286)",
                .smallBottomLeft: "(x:27,y:496)",
                .smallBottomRight: "(x:218,y:496)",
                
                .mudiumTop: "(x:27,y:76)",
                .mudiumCenter: "(x:27,y:286)",
                .mudiumBottom: "(x:27,y:496)",
                
                .largeTop: "(x:27,y:76)",
                .largeBottom: "(x:27,y:286)"
            ],
            // 6s P / 7P / 8P
            "414x736": [
                .smallTopLeft: "(x:33,y:38)",
                .smallTopRight: "(x:224,y:38)",
                .smallCenterLeft: "(x:33,y:232)",
                .smallCenterRight: "(x:224,y:232)",
                .smallBottomLeft: "(x:33,y:426)",
                .smallBottomRight: "(x:224,y:426)",
                
                .mudiumTop: "(x:33,y:38)",
                .mudiumCenter: "(x:33,y:232)",
                .mudiumBottom: "(x:33,y:426)",
                
                .largeTop: "(x:33,y:38)",
                .largeBottom: "(x:33,y:232)"
            ],
            // 14 Pro / 15 / 15 Pro
            "393x852": [
                .smallTopLeft: "(x:27,y:90)",
                .smallTopRight: "(x:208,y:90)",
                .smallCenterLeft: "(x:27,y:286)",
                .smallCenterRight: "(x:208,y:286)",
                .smallBottomLeft: "(x:27,y:482)",
                .smallBottomRight: "(x:208,y:482)",
                
                .mudiumTop: "(x:27,y:90)",
                .mudiumCenter: "(x:27,y:286)",
                .mudiumBottom: "(x:27,y:482)",
                
                .largeTop: "(x:27,y:90)",
                .largeBottom: "(x:27,y:286)"
            ],
            // 12 / 12 Pro / 13 / 13 Pro / 14
            "390x844": [
                .smallTopLeft: "(x:26,y:77)",
                .smallTopRight: "(x:206,y:77)",
                .smallCenterLeft: "(x:26,y:273)",
                .smallCenterRight: "(x:206,y:273))",
                .smallBottomLeft: "(x:26,y:469)",
                .smallBottomRight: "(x:206,y:469)",
                
                .mudiumTop: "(x:26,y:77)",
                .mudiumCenter: "(x:26,y:273)",
                .mudiumBottom: "(x:26,y:469)",
                
                .largeTop: "(x:26,y:77)",
                .largeBottom: "(x:26,y:273)"
            ],
            // X / Xs / 11 Pro / 12 mini    / 13 mini
            "375x812": [
                .smallTopLeft: "(x:23,y:71)",
                .smallTopRight: "(x:197,y:71)",
                .smallCenterLeft: "(x:23,y:261)",
                .smallCenterRight: "(x:197,y:261))",
                .smallBottomLeft: "(x:23,y:451)",
                .smallBottomRight: "(x:197,y:451)",
                
                .mudiumTop: "(x:23,y:71)",
                .mudiumCenter: "(x:23,y:261)",
                .mudiumBottom: "(x:23,y:451)",
                
                .largeTop: "(x:23,y:71)",
                .largeBottom: "(x:23,y:261)"
            ],
            // 6s / 7 / 8 / SE2
            "375x667": [
                .smallTopLeft: "(x:27,y:30)",
                .smallTopRight: "(x:200,y:30)",
                .smallCenterLeft: "(x:27,y:206)",
                .smallCenterRight: "(x:200,y:206))",
                .smallBottomLeft: "(x:27,y:382)",
                .smallBottomRight: "(x:200,y:382)",
                
                .mudiumTop: "(x:27,y:30)",
                .mudiumCenter: "(x:27,y:206)",
                .mudiumBottom: "(x:27,y:382)",
                
                .largeTop: "(x:27,y:30)",
                .largeBottom: "(x:27,y:206)"
            ],
            // unknow -- 这个设备不知具体是什么机型。 数据不可用
            "360x780": [
                .smallTopLeft: "(x:0,y:0)",
                .smallTopRight: "(x:0,y:0)",
                .smallCenterLeft: "(x:0,y:0)",
                .smallCenterRight: "(x:0,y:0))",
                .smallBottomLeft: "(x:0,y:0)",
                .smallBottomRight: "(x:0,y:0)",
                
                .mudiumTop: "(x:0,y:0)",
                .mudiumCenter: "(x:0,y:0)",
                .mudiumBottom: "(x:0,y:0)",
                
                .largeTop: "(x:0,y:0)",
                .largeBottom: "(x:0,y:0)"
            ],
            // SE
            "320x568": [
                .smallTopLeft: "(x:14,y:30)",
                .smallTopRight: "(x:165,y:30)",
                .smallCenterLeft: "(x:14,y:200)",
                .smallCenterRight: "(x:165,y:200))",
                .smallBottomLeft: "(x:0,y:0)",
                .smallBottomRight: "(x:0,y:0)",
                
                .mudiumTop: "(x:14,y:30)",
                .mudiumCenter: "(x:14,y:200)",
                .mudiumBottom: "(x:0,y:0)",
                
                .largeTop: "(x:14,y:30)",
                .largeBottom: "(x:0,y:0)"
            ]
        ]
    }
}

有了小组件类型 + 位置信息 + 小组件大小, 就可以进行截图了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
private extension WidgetSizeManager {
    
    func getSize(from string: String) -> CGSize {
        // 23x189
        let whArr = string.components(separatedBy: "x")
        if let float_width = whArr.first?.float,
           let float_height = whArr.last?.float
        {
            let width = CGFloat(float_width)
            let height = CGFloat(float_height)
            return CGSize(width: width, height: height)
        }
        
        return .zero
    }
    
    func getPoint(from string: String) -> CGPoint {
        let string = string
        // (x:14,y:30)
        let rmX = string.replacingOccurrences(of: "(x:", with: "")
        let rmY = rmX.replacingOccurrences(of: "y:", with: "")
        let last = rmY.replacingOccurrences(of: ")", with: "")
        let xyArr = last.components(separatedBy: ",")
        
        if let float_x = xyArr.first?.float,
           let float_y = xyArr.last?.float
        {
            let x = CGFloat(float_x)
            let y = CGFloat(float_y)
            return CGPoint(x: x, y: y)
        }
        
        return .zero
    }
    
    /// 获取小组件 frmae
    /// - Parameter widgetPosition: 位置信息
    /// - Returns: rect
    func getBgImageFrame(for widgetPosition: WidgetPosition) -> CGRect {
        // current device size
        let size: CGSize = UIScreen.main.bounds.size
        let sizeStr: String = "\(size.width)x\(size.height)".replacingOccurrences(of: ".0", with: "")
        
        // widget size
        let deviceWidgetSizeInfo = deviceWidgetSizeInfo
        guard let widgetSizeDict = deviceWidgetSizeInfo[sizeStr],
              let widgetSizeStr = widgetSizeDict[widgetPosition.widgeSize]
        else { return .zero }
        
        let widgeSize = getSize(from: widgetSizeStr)
        guard widgeSize != .zero else { return .zero }
        
        // widget frame
        let deviceWidgetPointInfo = deviceWidgetPointInfo
        guard let widgetPointDict = deviceWidgetPointInfo[sizeStr],
              let widgetPointStr = widgetPointDict[widgetPosition]
        else { return .zero }
        
        let widgePoint = getPoint(from: widgetPointStr)
        guard widgePoint != .zero else { return .zero }
        
        let widgetFrame = CGRect(origin: widgePoint, size: widgeSize)
        return widgetFrame
    }
    
    /// 指定位置获取透明组件背景图
    /// - Parameter widgetPosition: 位置信息
    /// - Returns: 背景图, 返回空代表获取失败
    func getBgImage(for widgetPosition: WidgetPosition, uiStyle: UIUserInterfaceStyle) -> UIImage? {
        
        let widgetFrame = getBgImageFrame(for: widgetPosition)
        if  widgetFrame == .zero { return nil }
        
        
        // crop Image
        if uiStyle == .light,
           let lightImage = TransparentTool.lightImage
        {
            return lightImage.crop(toRect: widgetFrame)
        }
        
        if uiStyle == .dark,
           let darkImage = TransparentTool.darkImage
        {
            return darkImage.crop(toRect: widgetFrame)
        }
        
        return nil
    }
}

extension UIImage {
    func crop(toRect rect: CGRect) -> UIImage? {
        let scale = UIScreen().scale
        let realRect: CGRect = .init(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale)
        
        guard let cgImage = self.cgImage else { return nil }
        guard let croppedCGImage = cgImage.cropping(to: realRect) else { return nil }
        
        let croppedImage = UIImage(cgImage: croppedCGImage)
        return croppedImage
    }
}

最后将对外暴露的方法提供如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct WidgetSizeManager {
    private init () {}
    /// 组件大小
    enum WidgetSize {
        case small, mudium, large
    }
    
    /// 组件位置
    enum WidgetPosition: CaseIterable {
        case smallTopLeft, smallTopRight, smallCenterLeft, smallCenterRight, smallBottomLeft, smallBottomRight
        case mudiumTop, mudiumCenter, mudiumBottom
        case largeTop, largeBottom
        
        var widgeSize: WidgetSize {
            switch self {
            case .smallTopLeft, .smallTopRight, .smallCenterLeft, .smallCenterRight, .smallBottomLeft, .smallBottomRight:
                return .small
            case .mudiumTop, .mudiumCenter, .mudiumBottom:
                return .mudium
            case .largeTop, .largeBottom:
                return .large
            }
        }
        
        static var smallCases: [Self] { WidgetPosition.allCases.filter { $0.widgeSize == .small } }
        static var mudiumCases: [Self] { WidgetPosition.allCases.filter { $0.widgeSize == .mudium } }
        static var largeCases: [Self] { WidgetPosition.allCases.filter { $0.widgeSize == .large } }
    }
    
    /// 获取小组件 frmae
    /// - Parameter widgetPosition: 位置信息
    /// - Returns: rect
    static func getBgImageFrame(for widgetPosition: WidgetPosition) -> CGRect {
        let mgr = WidgetSizeManager()
        return mgr.getBgImageFrame(for: widgetPosition)
    }
    
    /// 指定位置获取透明组件背景图
    /// - Parameter widgetPosition: 位置信息
    /// - Returns: 背景图, 返回空代表获取失败
    static func getBgImage(for widgetPosition: WidgetPosition, uiStyle: UIUserInterfaceStyle) -> UIImage? {
        let mgr = WidgetSizeManager()
        return mgr.getBgImage(for: widgetPosition, uiStyle: uiStyle)
    }
}

以上就是根据小组件类型进行截图的部分, 下面介绍上传图片和保存

提示用户上传 lightMode / darkMode 下的两张图并存储起来

简单来说, 对外只需要暴露存取方法即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct TransparentTool {
    
    /// 获取 lightMode 下的图
    static var lightImage: UIImage? {
        getImage(with: lightImagePath)
    }
    
    /// 获取 darkMode 下的图
    static var darkImage: UIImage? {
        getImage(with: darkImagePath)
    }
    
    /// 保存 lightMode 图片
    /// - Parameter img: 图片
    static func saveLightImage(img: UIImage) {
        setImage(with: lightImagePath, image: img)
    }
    
    /// 保存 darkMode 图片
    /// - Parameter img: 图片
    static func saveDarkImage(img: UIImage) {
        setImage(with: darkImagePath, image: img)
    }
    
}

具体存取逻辑应该封装起来, 内部实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// MARK: -  Private Functions
private extension TransparentTool {
    
    static private let documentPath: String? = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
    static private let folderName: String = "transparentWidget/"
    static private let lightImagePath: String =  folderName + "lightImage.png"
    static private let darkImagePath: String = folderName + "darkImage.png"
    
    static private func getImage(with pathName:String) -> UIImage? {
        if let documentPath = documentPath {
            let imagePath = documentPath + "/" + pathName
            if let data = FileManager.default.contents(atPath: imagePath),
               let image = UIImage(data: data) {
                return image
            }
        }
        
        return nil
    }
    
    static private func setImage(with pathName:String, image: UIImage) {
        createFolder()
        
        if let documentPath = documentPath {
            let imagePath = documentPath + "/" + pathName
            
            if let data = image.pngData(){
                if FileManager.default.fileExists(atPath: imagePath) {
                    do {
                        try FileManager.default.removeItem(atPath: imagePath)
                    } catch {
                        print("删除旧透明图片失败: \(error.localizedDescription)")
                    }
                }
                FileManager.default.createFile(atPath: imagePath, contents: data)
            }
        }
    }
    
    static private func createFolder() {
        if let documentPath = documentPath {
            let folderPath = documentPath + "/" + folderName + "/"
         
            if !FileManager.default.fileExists(atPath: folderPath) {
                do {
                    try FileManager.default.createDirectory(atPath: folderPath, withIntermediateDirectories: true)
                }catch{
                    print("创建透明组件文件夹失败: \(error.localizedDescription)")
                }
            }
        }
    }
}

由此一个透明组件的核心逻辑就实现了. 剩下的就是集成到项目进行工程化的部分了.

参考链接:

苹果官方设计引导

iOS Widget小组件大小和位置(透明组件)


THE END.

本文由作者按照 CC BY 4.0 进行授权