iOS14 Widget开发踩坑(四)伪透明的实现和其他研究
- 前言
- 分析
- 实施
- 暗黑模式切换
- 其他问题的研究:去除小组件的名字
- 本篇参考文献
- 小组件参考文献总结
前言
Widgets
A widget elevates key content from your app and displays it where people can see it at a glance on iPhone, iPad, and Mac. Useful and delightful, widgets can also help people personalize their iPhone Home screens in unique ways.
——from《Human Interface Guidelines》
桌面小组件作为新的一项流量入口,设计的好看与否也关乎到用户的留存,好看的小组件可以美化用户的桌面,抓住用户爱美的心。于是产品在看到系统自带的电池组件和现在市场上出现的产品后把需求提到了我这里:伪透明。
其实这个需求在iOS14没有正式推出时的Beta版就已经被提出了,一直拖到现在我才写出了一份较为满意的方案。
分析
首先我们看到系统的电池组件,是一层毛玻璃特效似得背景,能够轻微的透过部分桌面,这就和其它组件形成了鲜明的对比。但是,根据目前的结论而言,这种特效是iOS系统应用独享的,没有公开api。而产品甚至更过分的想要完全透明。而这样的需求,其实在有的美化应用的透明图标就已经提供了答案!视觉是可以被欺骗的,有的生物在在自然界有了一项特殊技能:保护色。我们可以根据这个原理来实现这个虚伪的透明效果。
实施
在调研了AppStore中有这一功能的应用之后我们都发现,要想实现透明组件,都需要用户的一张空白屏幕截图。
那么事情就很简单了,我们只需要从用户提供的这张屏幕截图中提取出各个小组件位置的截图,再将这个图片设置为小组件的背景,这样就可以实现变色龙的伪装了嘛。幸好,小组件的尺寸和位置在每一款机型上都是固定的(注:貌似某些机型在升级到iOS 14后图标的位置有所改变)。
下面是我的垃圾代码,拉出来给大家批评一下
private var widthM:CGFloat = 0.0;
private var heightM:CGFloat = 0.0;
private var heightL:CGFloat = 0.0;
private var midY:CGFloat = 0.0;
private var btmY:CGFloat = 0.0;
private var widgetLeftX:CGFloat = 0.0;
private var widgetRightX:CGFloat = 0.0;
var iconSize: CGSize = CGSize.zero
// 顶部的Y轴
var topY: CGFloat = 0
// 两个图标的Y轴间隙
var ySpacing: CGFloat = 0
// 倍图数
var multiple: NSInteger = 1
// 顶部中尺寸组件的尺寸信息
var topMediumRect: CGRect = CGRect.zero
// 中部中尺寸组件的尺寸信息
var middleMediumRect: CGRect = CGRect.zero
// 底部中尺寸组件的尺寸信息
var bottomMediumRect: CGRect = CGRect.zero
// 左上小尺寸组件的尺寸信息
var topLeftSmallRect: CGRect = CGRect.zero
// 左中小尺寸组件的尺寸信息
var middleLeftSmallRect: CGRect = CGRect.zero
// 左底小尺寸组件的尺寸信息
var bottomLeftSmallRect: CGRect = CGRect.zero
// 右上小尺寸组件的尺寸信息
var topRightSmallRect: CGRect = CGRect.zero
// 右中小尺寸组件的尺寸信息
var middleRightSmallRect: CGRect = CGRect.zero
// 右底小尺寸组件的尺寸信息
var bottomRightSmallRect: CGRect = CGRect.zero
// 顶部大尺寸组件的尺寸信息
var topLargeRect: CGRect = CGRect.zero
// 底部大尺寸组件的尺寸信息
var bottomLargeRect: CGRect = CGRect.zero
private init() {
if IS_IPHONE_6 {
self.iconSize = CGSize(width: 60, height: 60);
self.multiple = 2;
self.ySpacing = 28.0;
self.topY = 30.0;
widthM = 322.0;
heightM = 148.0;
heightL = 324.0;
midY = self.topY + (60.0 + self.ySpacing) * 2.0;
btmY = self.topY + (60.0 + self.ySpacing) * 4.0;
widgetLeftX = (375 - widthM) / 2;
widgetRightX = 375 - heightM - widgetLeftX;
} else if IS_IPHONE_X {
self.iconSize = CGSize(width: 60, height: 60);
self.multiple = 3;
self.ySpacing = 35.0;
self.topY = 71.0; // 区分iOS 12mini 74 和iPhoneX 71
widthM = 329.0;
heightM = 155.0;
heightL = 345.0;
midY = self.topY + (60.0 + self.ySpacing) * 2.0;
btmY = self.topY + (60.0 + self.ySpacing) * 4.0;
widgetLeftX = (375 - widthM) / 2;
widgetRightX = 375 - heightM - widgetLeftX;
} else if IS_IPHONE_XR {
self.iconSize = CGSize(width: 64, height: 64);
self.multiple = 2;
self.ySpacing = 41;
self.topY = 80;
widthM = 360.0;
heightM = 169.0;
heightL = 376.0;
midY = self.topY + (64.0 + self.ySpacing) * 2.0;
btmY = self.topY + (64.0 + self.ySpacing) * 4.0;
widgetLeftX = (414 - widthM) / 2;
widgetRightX = 414 - heightM - widgetLeftX;
} else if IS_IPHONE_XS_MAX {
self.iconSize = CGSize(width: 64, height: 64);
self.multiple = 3;
self.ySpacing = 41.0;
self.topY = 76.0;
widthM = 360.0;
heightM = 169.0;
heightL = 376.0;
midY = self.topY + (64.0 + self.ySpacing) * 2;
btmY = self.topY + (64.0 + self.ySpacing) * 4;
widgetLeftX = (414 - widthM) / 2;
widgetRightX = 414 - heightM - widgetLeftX;
} else if IS_IPHONE_6P {
self.iconSize = CGSize(width: 60, height: 60);
self.multiple = 3;
self.ySpacing = 37.0;
self.topY = 38.0;
widthM = 348.0;
heightM = 158.0;
heightL = 357.0;
midY = self.topY + (60.0 + self.ySpacing) * 2.0;
btmY = self.topY + (60.0 + self.ySpacing) * 4.0;
widgetLeftX = (414 - widthM) / 2;
widgetRightX = 414 - heightM - widgetLeftX;
} else if IS_IPHONE_12 {
self.iconSize = CGSize(width: 60, height: 60);
self.multiple = 3;
self.ySpacing = 38.0;
self.topY = 77.0;
widthM = 338.0;
heightM = 158.0;
heightL = 354.0;
midY = self.topY + (60.0 + self.ySpacing) * 2.0;
btmY = self.topY + (60.0 + self.ySpacing) * 4.0;
widgetLeftX = (390 - widthM) / 2;
widgetRightX = 390 - heightM - widgetLeftX;
} else if IS_IPHONE_12P_MAX {
self.iconSize = CGSize(width: 64, height: 64);
self.multiple = 3;
self.ySpacing = 42;
self.topY = 82;
widthM = 364;
heightM = 170;
heightL = 382;
midY = self.topY + (64 + self.ySpacing) * 2;
btmY = self.topY + (64 + self.ySpacing) * 4;
widgetLeftX = (428 - widthM) / 2;
widgetRightX = 428 - heightM - widgetLeftX;
} else {
self.iconSize = CGSize.zero;
self.multiple = 0;
self.topY = 0;
}
// 中尺寸3种位置
self.topMediumRect = CGRect(x: widgetLeftX, y: self.topY, width: widthM, height: heightM);
self.middleMediumRect = CGRect(x: widgetLeftX, y: midY, width: widthM, height: heightM);
self.bottomMediumRect = CGRect(x: widgetLeftX, y: btmY, width: widthM, height: heightM);
// 小尺寸6种位置
self.topLeftSmallRect = CGRect(x: widgetLeftX, y: self.topY, width: heightM, height: heightM);
self.middleLeftSmallRect = CGRect(x: widgetLeftX, y: midY, width: heightM, height: heightM);
self.bottomLeftSmallRect = CGRect(x: widgetLeftX, y: btmY, width: heightM, height: heightM);
self.topRightSmallRect = CGRect(x: widgetRightX, y: self.topY, width: heightM, height: heightM);
self.middleRightSmallRect = CGRect(x: widgetRightX, y: midY, width: heightM, height: heightM);
self.bottomRightSmallRect = CGRect(x: widgetRightX, y: btmY, width: heightM, height: heightM);
// 大尺寸2种位置
self.topLargeRect = CGRect(x: widgetRightX, y: self.topY, width: widthM, height: heightL);
self.bottomLargeRect = CGRect(x: widgetRightX, y: midY, width: widthM, height: heightL);
图片截取:
extension UIImage {
static func cuttingCGImage(cgImage:CGImage, originalRect:CGRect, multiple: NSInteger) -> UIImage {
let cropRect = CGRect(x:originalRect.origin.x * CGFloat(multiple),
y:originalRect.origin.y * CGFloat(multiple),
width: originalRect.size.width * CGFloat(multiple),
height: originalRect.size.height * CGFloat(multiple))
let newImageRef = cgImage.cropping(to: cropRect)
return UIImage.init(cgImage: newImageRef!)
}
}
保存流程(我是保存到UserDefault里,也可以以其他方式保存)
// 裁剪
let sourceImageRef = image.cgImage
var images: [UIImage] = []
// 中尺寸
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.topMediumRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.middleMediumRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.bottomMediumRect, multiple: 2))
// 小尺寸
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.topLeftSmallRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.topRightSmallRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.middleLeftSmallRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.middleRightSmallRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.bottomLeftSmallRect, multiple: 2))
images.append(UIImage.cuttingCGImage(cgImage: sourceImageRef!, originalRect: helper.bottomRightSmallRect, multiple: 2))
// 大尺寸没写
// 原图
images.append(image)
let ud = UserDefaults(suiteName: kAppGroupStr)
let titles = isDark ? kTransparentWidgetDarkImageNames : kTransparentWidgetLightImageNames
// 保存
for item in images {
let idx = images.firstIndex(of: item)!
let imageData = item.pngData()
ud?.setValue(imageData, forKey: titles[idx])
ud?.synchronize()
debugPrint(titles[idx] + "\(idx) 成功")
}
暗黑模式切换
首先,暗黑模式的环境变量获取在Widget是在View上以
@Environment(\.colorScheme) var colorScheme
的形式获取。而无法在getTimeline中判断当前是否是黑暗模式,我们就无法进行单一的判断,只能在getTimeline准备数据时为小组件准备好两份图片,再到View上以colorScheme来区分显示哪一张!(注:这是我看了好久之后得出的方法,如果你有更好的方法请告诉我,欢迎大家一起交流!)
如果是小组件还支持用户自定义背景图呢?存3张?
其实是否用透明是可以以一项Intent配置来选择的,那么我们就能在getTimeline判断是否为透明组件,这样的话,自定义和光亮模式的图片就可以在这里通过判断而加载一张。
//getTimeLine
func getTimeline(for configuration: MediumWidgetIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let currentDate = Date()
var widgetModel = TestModel()
let dbModel = getDBModel(config: configuration)
var bgName = dbModel.bgImageName
let areaIdx = Int(configuration.area?.identifier ?? "-1")
if areaIdx != -1 && areaIdx != 0 {
bgName = kTransparentWidgetLightImageNames[areaIdx! - 1]
widgetModel.darkImage = getDarkBgImage(idx: areaIdx! - 1)
widgetModel.isClear = true
}
widgetModel.bgImage = getMediumBgImage(name: bgName)
let entry = MediumEntry(date: currentDate, model: widgetModel)
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
//View
struct TestView:View {
@Environment(\.colorScheme) var colorScheme
var model: TestModel
var body: some View {
ZStack {
if model.isClear && colorScheme == .dark{
model.darkImage
.resizable()
.aspectRatio(contentMode: .fill)
} else {
model.bgImage
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity, alignment: .center)
}
}
其他问题的研究:去除小组件的名字
其实,我们可以看到每次我们添加一个小组件到桌面时,小组件下方都会附带主程序的应用名,这导致我们实现的透明效果很滑稽,尤其是一些名字很长的App看起来就很丑。。。
于是我们发现国外有两款App叫 Clear Speace 和 yidget
它们的主程序本身和小组件下方都不带有名字!!!而在资源库里可以检索到应用本身???
已知主程序显示在桌面上的名字取决于项目配置中的DisplayName
而WidgetExtension在桌面上显示的名字却不受其本身的DisplayName影响,是受到主程序的DisplayName影响的,我写的小组件的DisplayName为MainWidget,但是在桌面上显示的却是主程序的DisplayName
所以,想要Widget下面不显示名字,那么主程序的下方也得是不显示的!!!
然而,当主程序的DisplayName为空,或者是空格符时,在资源库是无法被搜索到的!!!
并且,根据苹果审核规范,这样是无法过审的!!!
Bundle name - is folder name, where your app (including executable file and all resources) will be stored (Cool Program.app)。建议不要修改bundle name
Bundle display name - is what will be shown on iPhone screen,即当你安装该app到iPhone上显示的name。
注意:Bundle Display name must correspond to Bundle name,即bundle display name和bundle name不能相差太远。例如bundle name设置为 TheApplication, 而 bundle display name设置为“金瓶梅”,则apple会拒绝你的app。
所以我有两个疑问一直没有解决:
1.它们是怎么做到桌面不显示名字但是资源库搜索却能正确搜索到?
2.它们是怎么过审的?