diamont1001/blog

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'")
    }
    ...
}

字体不等宽问题

默认的字体是不等宽的,比如 81 宽,但是有些时候我们想要等宽字体怎么办呢?为此,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 弹出的位置会很奇怪。下面列出几种规范情况:

一、按钮点击后弹出菜单

ActionSheetButton 联动的,直接写在 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())

附:List 样式详解

swiftUI代码编译过程,系统内存使用突然暴增,乃至卡死机

  • 血与泪的教训:swiftUI变量在定义时最好加上类型

在代码编译的时候,突然发现编译好久,甚至还弹出窗口提示说内存不足,看了下其实没开几个程序,搞的我又升级系统又啥的,还是没解决,最后解决过程记录一下,避免再次踩坑:

打开系统自带的【Activity Monitor】看了下,如图:

image

image

由图看到 SourceKitServiceswift-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 上的测试包同步的数据是生产环境数据。

image

Coredata Cloudkit 同步突然失败

之前做的App,后来做新功能新增了一个数据表,原来的那个表也增加一一些字段,然后就开始同步不了了,查了下都找不到解决办法,拖了好久,今天才偶然见到这个 帖子,解决办法如下:

修改过数据表的话,需要点一下 https://icloud.developer.apple.com/dashboard/ 里的 Deploy Schema Changes…,将这些更改部署到 CloudKit 仪表板上的生产环境。

image

如果遇到,在开发过程中新增 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 在黑夜模式下会有问题。

具体做法:

  1. 定义好颜色
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
}
  1. 在界面中使用 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 + 的:

首先要保证以下几个步骤:

  1. 要真机测试
  2. XCode 上添加WiFi读取权限:Target -> "Signing & Capabilities" and adding "Access WiFi Information"
  3. 地理位置权限说明:Info.plist add: NSLocationWhenInUseUsageDescription

image

image

以下代码已封装,可以直接调用:

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 有时候数据更新后,列表数据没及时更新。

解决:https://stackoverflow.com/questions/58643094/how-to-update-fetchrequest-when-a-related-entity-changes-in-swiftui

.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

解决:

  1. NavigationLink 放到页面的其中一个 View 的 background 里,利用 isActive 控制展示
  2. toolbar 里点击后把 isActive 置为 true