iOS开发之swiftUI学习以及采坑记录
diamont1001 opened this issue · 0 comments
苹果的文档写的还是不错的,开发前可以多看看官方文档:
常用记录
养成好习惯
- 给
@State
变量加上private
:@State private var score: Int = 0
- 尽量使用默认
.padding()
: 如果不加任何参数的话,.padding()
会根据屏幕大小而进行自适应的 - 多个弹出UI组件不能同时放到同个UI组件(包含子组件里),比如
.alert()
,需要分散到各个互不相嵌的组件
键盘弹出后,点击空白不会自动隐藏
修复:
以上方法要注意,UIApplication.shared.addTapGestureRecognizer
可能会导致整个App都生效,而且最好避免重复调用。
如果只是需要在某些子页面隐藏键盘的话,以下方法可能更适用:
// 手动隐藏键盘
// 调用:UIApplication.shared.hideKeyboard()
// 也可以直接在需要的页面元素添加 .onTapGesture { UIApplication.shared.hideKeyboard() }
extension UIApplication {
func hideKeyboard() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
键盘弹出遮挡页面
解决:https://stackoverflow.com/a/59514820
// Form {}.keyboardResponsive()
struct KeyboardResponsiveModifier: ViewModifier {
@State private var offset: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, offset)
.onAppear {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notif in
let value = notif.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
let height = value.height
let bottomInset = UIApplication.shared.windows.first?.safeAreaInsets.bottom
self.offset = height - (bottomInset ?? 0)
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { notif in
self.offset = 0
}
}
}
}
extension View {
func keyboardResponsive() -> ModifiedContent<Self, KeyboardResponsiveModifier> {
return modifier(KeyboardResponsiveModifier())
}
}
SwiftUI 图片展示
SwiftUI 计算属性
struct ViewExample: View {
private var value: Bool
// 计算属性
private var text: String {
"Toggle is " + (value ? "'on'" : "'off'")
}
...
}
字体不等宽问题
默认的字体是不等宽的,比如 8
比 1
宽,但是有些时候我们想要等宽字体怎么办呢?为此,SwiftUI
也给出了解决方案:
Text("123123")
.font(.system(size: 15, design: .monospaced))
多行输入框 texteditor
样式问题
传送门:https://serialcoder.dev/text-tutorials/swiftui/texteditor-in-swiftui/
TextField 输入框输入状态检测
TextField("", text: $inputText, onEditingChanged: { (changed) in
if changed {
// print("text edit has begun")
} else {
// print("committed the change")
}
})
ActionSheet
写法规范(兼容iPad)
ActionSheet
在手机端是在底部弹出菜单,但是在 iPad 端会在页内弹出菜单,写法不规范的话,会导致菜单在 iPad 弹出的位置会很奇怪。下面列出几种规范情况:
一、按钮点击后弹出菜单
ActionSheet
跟 Button
联动的,直接写在 Button
后面:
Button(action: {
self.isDeletePresented = true
}) {
Text("Delete")
.foregroundColor(.red)
}
.actionSheet(isPresented: $isDeletePresented, content: {
ActionSheet(title: Text("Cannot be restored after deletion"),
buttons: [
.destructive(Text("Delete")) {
// do something delete here
// ...
},
.cancel({
})
]
)
})
二、列表中的弹出菜单
有一些弹出菜单是跟列表联动的,比如列表项的删除二次确认弹出菜单,需要写在 List
里面的 Foreach
下面:
List {
ForEach(list, id: \.self) { item in
Text("\(item.name)")
}
.onDelete(perform: { offsets in
toDeleteSet = offsets
isPresentingDeleteItem.toggle()
})
.actionSheet(isPresented: $isPresentingDeleteItem, content: {
ActionSheet(title: Text("Would you like to delete this item? This item will be deleted from all of your devices."),
buttons: [
.destructive(Text("Delete")) {
// do something delete here
// ...
},
.cancel({
})
]
)
})
}
List item 里的 button 点击会导致整行被点击
解决:使用 .buttonStyle(BorderlessButtonStyle())
List {
...
}
.buttonStyle(BorderlessButtonStyle())
swiftUI代码编译过程,系统内存使用突然暴增,乃至卡死机
- 血与泪的教训:swiftUI变量在定义时最好加上类型
在代码编译的时候,突然发现编译好久,甚至还弹出窗口提示说内存不足,看了下其实没开几个程序,搞的我又升级系统又啥的,还是没解决,最后解决过程记录一下,避免再次踩坑:
打开系统自带的【Activity Monitor】看了下,如图:
由图看到 SourceKitService
和 swift-frontend
两个进程占用内存都好几十G,这很不正常。赶紧先把以上两个进程强制退出(双击进程-》强制退出),电脑马上恢复正常。
然后上网查了一下,找到一篇文章说到,有可能是 View
的传递的参数类型不确定导致的,看了下代码,并进行测试定位,最终定位到了问题代码所在,如下:
...
let UNIT_NUMBER_OFFSET_Y = 4.0 // 就是这里,没有定义好类型,然后下面调用时编译器找不到准确类型,导致崩了
TextField("", text: $kilometer)
.offset(y: UNIT_NUMBER_OFFSET_Y) // 这里需要的是 CGFloat 类型
解决:把传递的参数类型定义好即可:
...
let UNIT_NUMBER_OFFSET_Y: CGFloat = 4.0 // 这里把类型定义好就行
TextField("", text: $kilometer)
.offset(y: UNIT_NUMBER_OFFSET_Y)
写少了一个.
,编译通过但是页面会卡死
比如以下的这种情况一定要注意:
Text("Hello World")
background(Color.yellow) // 少了一个点
正确应该是这样:
Text("Hello World")
.background(Color.yellow)
系统分享
// 使用方法:
// @State private var showShareSheet: Bool = false
// @State var shareSheetItems: [Any] = []
//
// // 分享按钮
// Button(action: {
// self.shareSheetItems = ["分享的内容,可以是图片、文字等"]
// self.showShareSheet.toggle()
// }) {
// Image(systemName: "arrowshape.turn.up.left")
// }
// .sheet(isPresented: $showShareSheet, content: {
// ActivityViewController(activityItems: self.$shareSheetItems)
// })
//
//
// Created by 陈精任 on 2021/11/19.
//
import SwiftUI
struct ActivityViewController: UIViewControllerRepresentable {
@Binding var activityItems: [Any]
var excludedActivityTypes: [UIActivity.ActivityType]? = nil
func makeUIViewController(context: UIViewControllerRepresentableContext<ActivityViewController>) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems,
applicationActivities: nil)
controller.excludedActivityTypes = excludedActivityTypes
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityViewController>) {}
}
URL分享时,微信识别不了链接内容
调用系统分享,选择微信分享一个链接时,微信识别不了链接内容,这是因为在分享的时候没有把内容设置成链接。
解决:把分享的 String 类型链接改成NSURL 类型即可:
NSURL(string: "https://www.xxx.com/xxx")
Coredata Cloudkit 开发环境没数据
开发的时候,一切都就绪,但是开发环境就是没看到数据。原因是开发的时候使用模拟器是没法将数据同步到 iCloud 的,因为模拟器没有登录 iCloud。
iCloud: https://icloud.developer.apple.com/dashboard/database
解决:插上真机,XCode 进行真机测试一下即可。
注意:TestFlight 上的测试包同步的数据是生产环境数据。
Coredata Cloudkit 同步突然失败
之前做的App,后来做新功能新增了一个数据表,原来的那个表也增加一一些字段,然后就开始同步不了了,查了下都找不到解决办法,拖了好久,今天才偶然见到这个 帖子,解决办法如下:
修改过数据表的话,需要点一下 https://icloud.developer.apple.com/dashboard/ 里的 Deploy Schema Changes…
,将这些更改部署到 CloudKit 仪表板上的生产环境。
如果遇到,在开发过程中新增 entity 之后,在 cloudkit 网站 development 上看不到有新 schema,那是因为没有在真机测试的原因。因为模拟器是没有登录 icloud 账号的所以同步不上去,而 testflight 是生产环境数据,所以,需要把手机直接用线连接电脑进行真机测试之后,马上就能在 cloudkit 网站看到新的数据变化了。
SwiftUI视图保存为图片
参考:https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image
以上是使用 UIImage.snapshot()
实现的。
UIImage.snapshot() 在 iOS 15 出现内容向下偏移的 BUG
参考:https://www.vinzius.com/post/how-to-remove-padding-when-snapshotting-swiftui-view-ios15/
SwiftUI 黑夜模式兼容(Light/Dark)
参考:https://stackoverflow.com/a/62207329
SwiftUI 并不是自动兼容黑夜模式的,只有几个颜色自动兼容(比如:Color.primary, Color.secondary 等),但是并不是所有都兼容的(比如:Color.white, Color.black 等),所以如果没有特意去做兼容的话,最后 App 在黑夜模式下会有问题。
具体做法:
- 定义好颜色
import Foundation
import SwiftUI
extension Color {
#if os(macOS)
static let background = Color(NSColor.windowBackgroundColor)
static let secondaryBackground = Color(NSColor.underPageBackgroundColor)
static let tertiaryBackground = Color(NSColor.controlBackgroundColor)
#else
static let background = Color(UIColor.systemBackground)
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
static let tertiaryBackground = Color(UIColor.tertiarySystemBackground)
#endif
}
- 在界面中使用
Color.background
等作为背景色,Color.primary
,Color.secondary
作为内容色,其中Color.secondary
可以替代Color.gray
。
黑夜模式检测
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
...
var body: some View {
// ... to any view
.background(colorScheme == .dark ? Color.black : Color.white)
}
}
消除 SwiftUI View 之间的距离
默认的情况下,两个 View 之间会有一个距离,哪怕 padding 都设定为0,要解决这个问题,只需要加上 spacing: 0
VStack(spacing: 0) {
...
}
Int 数组 ForEach 的问题
- 列表 item 删除后导致的越界问题
- LazyVGrid 列表显示 Int 数组,数字重复会导致崩溃的问题
解决:使用 zip 进行循环
@State private var arr: Array<Int> = [1, 2, 3, 3, 2, 1]
LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))]) {
ForEach(Array(zip(arr.indices, arr)), id: \.0) { ndx, item in
Text("num: \(item)")
}
}
@binding 字段 init()
初始化
struct TestView: View {
@Binding var text: String
var height: CGFloat = 188
init(text: Binding<String>, height: CGFloat = 188) {
self._text = text // 使用下横线作为 binding 字段
self.height = height
}
...
}
@State 字段根据参数初始化
struct ABC: View {
var text: String
@State private vara inputText: String
init(text: String) {
_inputText = State(initialValue: text) // "_" 下横线表示,使用 State 进行初始化
}
}
SwiftUI 读取 View 尺寸大小
// 读取View尺寸()
extension View {
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
用法:
@State private var viewSize: CGSize = CGSize()
@State private var text: String = "Hello World"
...
Text("\(text)")
.readSize(onChange: { size in
self.viewSize = size
})
Text 不想被截断显示...
很多时候我们不想要文字被截断,可以做以下控制:
Text("Hello world!!!")
.lineLimit(1)
.fixedSize(horizontal: true, vertical: true) // 显示全部文本内容
.frame(maxWidth: .infinity) // 还是会有被截断的机率,加上这行就可以了
@ObservedObject
对象里的某些字段变化,为什么界面不改变
很多时候,我们需要对一个对象进行监听变化的时候,都可以使用 @ObservedObject
标识符进行监听,但是对象里的字段需要设定为 @Published
,才会被监听。
struct PageTetris: View {
@ObservedObject var game = TetrisGameUI()
...
}
class TetrisGameUI : ObservableObject {
var rows: Int = 0 // 普通变量,不会被监听
@Published var status: Int = 0 // published 变量,才会被监听
...
}
SwiftUI定时器 setInterval
var timer : Timer?
...
// 开启定时器
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: update)
...
// 关闭定时器
timer?.invalidate()
timer = nil
func update(timer: Timer){
...
}
SwiftUI 获取 WiFi 名称(SSID)
网上找过很多例子,有一些是不支持 iOS 14 的,最后总结了下,以下方式支持 iOS 14 + 的:
首先要保证以下几个步骤:
- 要真机测试
- XCode 上添加WiFi读取权限:Target -> "Signing & Capabilities" and adding "Access WiFi Information"
- 地理位置权限说明:
Info.plist
add:NSLocationWhenInUseUsageDescription
以下代码已封装,可以直接调用:
import SwiftUI
import SystemConfiguration.CaptiveNetwork
import CoreLocation
final class NetworkManager: NSObject {
// 单例模式,调用:NetworkManager.shared.xxx
static let shared = NetworkManager()
private let manager: CLLocationManager = CLLocationManager()
struct NetworkInfo {
var interface: String
var success: Bool = false
var ssid: String?
var bssid: String?
}
private override init() {
super.init()
}
// 请求用户权限
func requestPermission() {
manager.requestWhenInUseAuthorization()
}
func fetchNetworkInfo() -> [NetworkInfo]? {
// 首先确保权限可用(如果还没请求过权限,那么第一次请求该方法会返回空值)
requestPermission()
if let interfaces: NSArray = CNCopySupportedInterfaces() {
var networkInfos = [NetworkInfo]()
for interface in interfaces {
let interfaceName = interface as! String
var networkInfo = NetworkInfo(interface: interfaceName,
success: false,
ssid: nil,
bssid: nil)
if let dict = CNCopyCurrentNetworkInfo(interfaceName as CFString) as NSDictionary? {
networkInfo.success = true
networkInfo.ssid = dict[kCNNetworkInfoKeySSID as String] as? String
networkInfo.bssid = dict[kCNNetworkInfoKeyBSSID as String] as? String
}
networkInfos.append(networkInfo)
}
return networkInfos
}
return nil
}
}
调用栗子:
Button(action: {
let infos = NetworkManager.shared.fetchNetworkInfo()
if let ssid = infos?.first?.ssid {
print("SSID: \(ssid)")
}
}) {
Text("Get current WiFi SSID")
}
如果是第一次访问,界面会弹出授权框,然后返回 SSID 为空,需要用户授权后再点一次按钮才可以正常返回 SSID 值。
当然,也可以选择在页面初始化的时候先调用 NetworkManager.shared.requestPermission()
,首次访问时,页面在打开的时候就已经给用户弹出授权框,这样用户在点击按钮的时候就不会再弹窗了。
SwiftUI 使用 Picker
会导致页面的 .onAppear
会重复触发
比如以下情况,onAppear
会在 Picker
每次选完后触发:
var body: some View {
VStack {
Picker("Type", selection: $method) {
ForEach(["1", "2"], id: \.self) {item in
Text("\(item)")
.tag(item)
}
}
}
.onAppear {
print("onAppear")
}
}
解决办法,自己进行排重,因为一个页面只需要触发一次就够了:
// 排重变量
@State private var onAppearDone: Bool = false
var body: some View {
VStack {
Picker("Type", selection: $method) {
ForEach(["1", "2"], id: \.self) {item in
Text("\(item)")
.tag(item)
}
}
}
.onAppear {
// 排重
if self.onAppearDone { return }
onAppearDone = true
print("onAppear")
}
}
CoreData 例子
import Foundation
import CoreData
public class Record: NSManagedObject, Identifiable {
@NSManaged public var date : Date?
@NSManaged public var timeElapsed : Int32
@NSManaged public var errors : Int16
@NSManaged public var victory : Bool
}
extension Record {
static func getAllRecords() -> NSFetchRequest<Record> {
let request: NSFetchRequest<Record> = Record.fetchRequest() as!
NSFetchRequest<Record>
let sortDescriptor = NSSortDescriptor(key: "date", ascending: true)
request.sortDescriptors = [sortDescriptor]
return request
}
}
Settings 配置本地存储例子
使用 @AppStorage
可以把变量绑定到本地存储 UserDefaults
:
struct PageSettings: View {
@AppStorage("key_showFavList") private var showFavList: Bool = false
var body: some View {
List {
Section {
Toggle(isOn: $showFavList) {
Text("Show Fav List")
}
}
}
...
}
CoreData 列表数据更新不同步
CoreData 有时候数据更新后,列表数据没及时更新。
.shadow()
阴影效果导致 SwiftUI 页面卡顿
最近有个页面发现了很严重的卡顿问题,查了很久才定位到原来是 .shadow()
的锅。
下面这个小组件使用了阴影效果,该组件被某个界面大量引用,导致页面卡顿:
Image(systemName: "xxxx")
.font(.system(size: 32))
.foregroundColor(Color.orange)
.frame(width: 65, height: 65)
.background(Color.white)
.cornerRadius(18)
.shadow(color: Color.secondary.opacity(0.1), radius: 2, x: -1, y: -1)
.shadow(color: Color.secondary.opacity(0.2), radius: 5, x: 3, y: 2)
解决:目前没什么好的解决办法,只能暂时删掉阴影效果,改用 border 效果顶一下吧,比如:
Image(systemName: "xxxx")
.font(.system(size: 32))
.foregroundColor(Color.orange)
.frame(width: 65, height: 65)
.background(Color.white)
.cornerRadius(18)
// .shadow(color: Color.secondary.opacity(0.1), radius: 2, x: -1, y: -1)
// .shadow(color: Color.secondary.opacity(0.2), radius: 5, x: 3, y: 2)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color.gray.opacity(0.09), lineWidth: 1)
)
平台判定插件
/*
* 平台判定条件封装
* 用法:
* Text("Hello World")
* .iOS { $0.padding(10) }
*
* @link https://www.hackingwithswift.com/quick-start/swiftui/swiftui-tips-and-tricks
*/
extension View {
func iOS<Content: View>(_ modifier: (Self) -> Content) -> some View {
#if os(iOS)
return modifier(self)
#else
return self
#endif
}
func macOS<Content: View>(_ modifier: (Self) -> Content) -> some View {
#if os(macOS)
return modifier(self)
#else
return self
#endif
}
func tvOS<Content: View>(_ modifier: (Self) -> Content) -> some View {
#if os(tvOS)
return modifier(self)
#else
return self
#endif
}
func watchOS<Content: View>(_ modifier: (Self) -> Content) -> some View {
#if os(watchOS)
return modifier(self)
#else
return self
#endif
}
}
SwiftUI 10个子组件的限制
SwiftUI 规定,所有的容器都不能返回超过10个子组件,一般我们会使用 ForEach 循环或者 List 去实现,但是有时候界面上的设计就是需要同级的子组件有超过10个,那怎么办呢?
方法:添加 Group
List {
Group {
Text("Row 1")
Text("Row 2")
Text("Row 3")
Text("Row 4")
Text("Row 5")
Text("Row 6")
}
Group {
Text("Row 7")
Text("Row 8")
Text("Row 9")
Text("Row 10")
Text("Row 11")
Text("Row 12")
}
}
打开本App的系统设置页面
func gotoAppPrivacySettings() {
guard let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) else {
assertionFailure("Not able to open App privacy settings")
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
info.plist 本地化(多语言支持),导致审核不通过
参考 https://medium.com/@guerrix/info-plist-localization-ad5daaea732a,增加一个 InfoPlist.strings
文件并本地化即可。
但是,上传到 AppStore 后,有可能会出现问题,会提示 info.plist
缺少权限描述而导致审核不通过。
解决方法:
按正常的在 info.plist
添加描述,同时按上面的说明去添加 InfoPlist.strings
多语言支持即可。
SwiftUI 画板(PencilKit)在黑夜模式(dark mode)下的颜色问题
使用 @State private var canvas = PKCanvasView()
进行画板功能实现,然后使用 canvas.image()
进行图像保存,在 dark mode
模式下会出现颜色的反转的问题。
解决:画板就不应该支持黑夜模式,参考:https://stackoverflow.com/a/64341486/20251459
1、先扩展 image
方法:
import PencilKit
extension PKDrawing {
// 保存图片时支持(白天/黑夜)模式选择
func image(from rect: CGRect, scale: CGFloat, userInterfaceStyle: UIUserInterfaceStyle) -> UIImage {
let currentTraits = UITraitCollection.current
UITraitCollection.current = UITraitCollection(userInterfaceStyle: userInterfaceStyle)
let image = self.image(from: rect, scale: scale)
UITraitCollection.current = currentTraits
return image
}
}
2、将 PKCanvasView
设置成 light
模式
init() {
canvas.overrideUserInterfaceStyle = .light
}
3、保存图片的时候,使用扩展的 image
方法
func saveImage() {
// getting image from Canvas
let image: UIImage = canvas.drawing.image(from: canvas.drawing.bounds, scale: 1, userInterfaceStyle: .light)
// saving to album
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
NavigationLink 在 toolbar 里,导致页面跳转失败的Bug
参考:https://stackoverflow.com/a/63602455/20251459
解决:
- 把
NavigationLink
放到页面的其中一个 View 的background
里,利用isActive
控制展示 toolbar
里点击后把isActive
置为true