前言
最近开始双休了,有点时间就想学习一下SwiftUI,我在之前的很长一段时间都在关注SwiftUI但是一直没有时间来系统的学习
主要功能
- 上传当前定位
- 添加好友
- 访问通讯录电话
- 地图展示定位轨迹
- app内购
技术选型
- SwiftUI
- Moya/Combine
- SnapKit
- HandyJSON
SwiftUI
一方面是为了学习,另一方面相比Swift或Objective-c写页面会更简单一些,虽然可以使用xib和storybroad,相比之下还是没有SwiftUI简洁(同一份UI几种方式实现,SwiftUI的代码量是最少的)。
之前只会iOS的时候并没有觉得iOS写页面繁复的问题,现在再来写iOS代码页面的时候才发现,这个过程挺繁琐的,需要写一大堆相似的代码。比如下面的代码:
let startTimeLabel = UILabel()
startTimeLabel.tag = 100
startTimeLabel.textColor = UIColor.init(hex: 0xB7B7B7)
startTimeLabel.textAlignment = .center
startTimeLabel.numberOfLines = 2
leftView.addSubview(startTimeLabel)
startTimeLabel.snp.makeConstraints { make in
make.left.equalTo(20)
make.top.equalTo(startLabel.snp_bottomMargin).offset(13)
make.right.equalTo(-20)
}
let endTimeLabel = UILabel()
endTimeLabel.text = dateFormater.string(from: Date())
endTimeLabel.textColor = UIColor.init(hex: 0xB7B7B7)
endTimeLabel.textAlignment = .center
endTimeLabel.numberOfLines = 2
rightView.addSubview(endTimeLabel)
endTimeLabel.snp.makeConstraints { make in
make.left.equalTo(20)
make.top.equalTo(startLabel.snp_bottomMargin).offset(13)
make.right.equalTo(-20)
}
复制代码
我经常在想有没有必要把这些UI组件实现一个copy协议,这样就可以通过copy
获取到一个新的实现,我们只需要把不一样的地方改一下,其他地方都不需要再写一遍。这样可以极大减少我们的工作量。但事实上我没有做过。
而SwiftUI却极大的减少了这样的问题,当然他没有解决上面我说的这种问题,虽然上面的这种情况还有,但是向一些布局的问题相较Swift和OC极大减少了代码量。另一方面SwiftUI通过struct来定义页面要比class更加节省内存,所以他的性能更好。但SwiftUI现在存在的问题也还蛮多的,不太建议使用到实际的项目中。而且他的更新还是很频繁的,每个版本新推出一些api也伴随着废弃了之前的一些api,兼容性不是很好,很多api需要做版本控制,挺费劲的,哎。。。
Moya
Moya做网络请求还是很香的,Moya有Moya/RxSwift
、Moya/ReactiveSwift
、Moya/Combine
和Moya
等可以使用的版本,每一个都使用不同的技术实现感兴趣的朋友可以自己了解一下,我这里选择的是Moya/Combine
SnapKit
如果所有的功能都使用SwiftUI实现还是很难的,不过好在可以混编,所以SnapKit就有必要了。
HandyJSON
这个就不多介绍了,很好用的一个json转模型工具
--- 万事俱备,开始我们的代码之路吧 ---
代码实现
首先有必要介绍一下常用的组件
VStack
VStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了
HStack
HStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了
ZStack
HStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了
Spacer
Spacer 这个就很重要了,因为SwiftUI的布局跟这个息息相关,Spacer在不同的Stack(VStack,HStack,ZStack)
中所表示的意思略有不同。
在VStack中表示距上/下多少距离,使用Spacer().frame(height: 20)
表示,代码所表达的意思是距离上/下20pt,具体是上还是下这得看相对于哪个视图来看。在VStack中Spacer的frame只能设置height属性或不设置frame(即Spacer()
),设置相当于填充剩余空间。
在HStack中表示距左/右多少距离,使用Spacer().frame(width: 20)
表示,代码所表达的意思是距离左/右20pt,具体是左还是右这得看相对于哪个视图来看。在VStack中Spacer的frame只能设置width属性或不设置frame(即Spacer()
),设置相当于填充剩余空间。
大家可以看看下面这个图辅助理解
NavigationView
NavigationView相当于UINavigationViewController,项目中只需要一个,其他的页面会自动继承NavigationView,如果项目中出现多个NavigationView,就会出现多个导航栏,表现显示如下
出现这个问题的原因是在项目中使用了多个NavigationView,要解决这个问题只需要删除第二个页面和第三个页面中的NavigationView就可以了
// 第一个页面
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink {
SecondView()
} label: {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Frist View")
}
.padding()
}
}.navigationTitle("Frist View")
}
}
复制代码
// 第二个页面
struct SecondView: View {
var body: some View {
NavigationView {
NavigationLink {
ThridView()
} label: {
Text("Second View")
}
}
.navigationTitle("Second View")
}
}
复制代码
// 第三个页面
struct ThridView: View {
var body: some View {
NavigationView {
Text("Hello, World!")
}.navigationTitle("Thrid View")
}
}
复制代码
NavigationLink
NavigationLink是用于页面跳转的用法有很多种,比较常用的有以下几种,更多用法可以在官网查询
- 点击跳转
NavigationLink {
ThridView()
} label: {
// 点击Second View就会跳转
Text("Second View")
}
复制代码
- 满足条件跳转
NavigationLink(destination: ThridView(), isActive: $showThrid) {
EmptyView()
}
复制代码
项目中遇到的问题
- 在SwiftUI项目中如何在AppDelegate中写逻辑
- 请求到的数据如何及时响应到页面上
- 在内购弹出时出现自动返回到上一个页面
- 部分页面跳转的时候会触发启动文件多次执行
- 页面出现ScrollView需要全屏展示即隐藏导航栏和状态栏
- 页面弹窗类似UIAlertController、加载中的提示动画
- 键盘遮挡输入框问题
问题解决方案
这里提供的解决方案是我在项目中使用的方案,不一定适用于所有场景,有更好的方案还请不吝赐教
q:在SwiftUI项目中如何在AppDelegate中写逻辑
SwiftUI项目中是没有AppDelegate文件的,那么如果我们需要在AppDelegate中实现逻辑的话,可以使用如下方式
@main
struct LocationTraceApp: App {
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
NavigationView {
if isNoFrist {
if LoginModel.shared.isLogin {
if !isNoVip || intoHome {
ContentView()
}
} else {
LoginView(source: .constant("Launch"))
}
} else {
Welcome()
}
}
.fullScreenCover(isPresented: $isNoVip) {
if LoginModel.shared.isLogin {
BuyView(type: "trial")
}
}
}
}
}
class AppDelegate: UIResponder, UIApplicationDelegate, BMKGeneralDelegate, BMKLocationAuthDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
//实现你的逻辑
return true
}
// 返回网络错误
func onGetNetworkState(_ iError: Int32) {
// print("网络错误:", iError)
}
// 返回授权验证错误
func onGetPermissionState(_ iError: Int32) {
// print("授权验证错误:", iError)
}
func onCheckPermissionState(_ iError: BMKLocationAuthErrorCode) {
}
}
复制代码
q:请求到的数据如何及时响应到页面上
参考方案: 正常情况下进入页面根据参数请求接口,把接口响应的数据显示出来,使用@Published在ViewModel中装饰属性,在使用的地方这样写@ObservedObject **var** vm = PersonListViewModel()
,然后访问对应的属性就好了。比如
**class** PersonListViewModel:NSObject, ObservableObject {
@Published **var** dataSource:Array<PersonModel> = []
@Published **var** toastText:String?
**func** getList() {
ApiHelper<Array<PersonModel>>.request(target: .personList) { data, code, message **in**
**self**.dataSource = data ?? []
} failure: { error, code, message **in**
**self**.toastText = message
}
}
复制代码
@ObservedObject **var** vm = PersonListViewModel();
**var** body: **some** View {
ScrollView {
LazyVStack {
ForEach(vm.dataSource.indices, id: \.**self**) {index **in**
VStack {
NavigationLink(destination: LocationRecode(userId: vm.dataSource[index].friend_id!)) {
RecordItem(data: vm.dataSource[index], onEdit: { id **in**
friendId = id
isEdit = **true**
})
.background(Color.white)
.cornerRadius(10)
}
Spacer().frame(height: 15)
}
}
}
.padding(.top, 15)
.padding(.leading, 15)
.padding(.trailing, 15)
}
}
复制代码
以上代码在大部分场景下都可以使用,那我们说一下在什么场景下不适用,及我的解决方案。
- 如上面这种情况,如果修改了数组中对象的属性值,界面将不会改变 我的解决方案是在写一个Observer对象,代码如下
import Foundation
protocol AnyObserver: AnyObject {
func remove()
}
struct ObserverOptions: OptionSet {
typealias RawValue = Int
let rawValue: Int
// 如果连续执行, 只执行最后一个,默认方式,也是推荐的
static let Coalescing = ObserverOptions(rawValue: 1)
// 同步执行, 会等上一个执行完成才执行下一个
static let FireSynchronously = ObserverOptions(rawValue: 1 << 1)
// 立即执行, 会监听到最开始的赋值,不会等上一个执行完成才执行下一个
static let FireImmediately = ObserverOptions(rawValue: 1 << 2)
}
//MARK: Observer
class Observer<Value> {
typealias ActionType = (_ oldValue: Value, _ newValue: Value) -> Void
let action: ActionType
let queue: OperationQueue
let options: ObserverOptions
fileprivate var coalescedOldValue: Value?
fileprivate var fireCount = 0
fileprivate weak var observable: Observable<Value>?
init(queue: OperationQueue = OperationQueue.main,
options: ObserverOptions = [.Coalescing],
action: @escaping ActionType) {
self.action = action
self.queue = queue
var optionsCopy = options
if optionsCopy.contains(ObserverOptions.FireSynchronously) {
optionsCopy.remove(.Coalescing)
}
self.options = optionsCopy
}
func fire(_ oldValue: Value, newValue: Value) {
fireCount += 1
let count = fireCount
if options.contains(.Coalescing) && coalescedOldValue == nil {
coalescedOldValue = oldValue
}
let operation = BlockOperation(block: { () -> Void in
if self.options.contains(.Coalescing) {
guard count == self.fireCount else { return }
self.action(self.coalescedOldValue ?? oldValue, newValue)
self.coalescedOldValue = nil
} else {
self.action(oldValue, newValue)
}
})
queue.addOperations([operation], waitUntilFinished: self.options.contains(.FireSynchronously))
}
}
extension Observer: AnyObserver {
func remove() {
observable?.removeObserver(self)
}
}
protocol ObservableType {
associatedtype ValueType
var value: ValueType { get }
func addObserver(_ observer: Observer<ValueType>)
func removeObserver(_ observer: Observer<ValueType>)
}
extension ObservableType {
@discardableResult func onSet(_ options: ObserverOptions = [.Coalescing],
action: @escaping (ValueType, ValueType) -> Void) -> Observer<ValueType> {
let observer = Observer<ValueType>(options: options, action: action)
addObserver(observer)
return observer
}
}
class Observable<Value> {
var value: Value {
didSet {
privateQueue.async {
for observer in self.observers {
observer.fire(oldValue, newValue: self.value)
}
}
}
}
fileprivate let privateQueue = DispatchQueue(label: "Observable Global Queue", attributes: [])
fileprivate var observers: [Observer<Value>] = []
init(_ value: Value) {
self.value = value
}
}
extension Observable: ObservableType {
typealias ValueType = Value
func addObserver(_ observer: Observer<ValueType>) {
privateQueue.sync {
self.observers.append(observer)
}
if observer.options.contains(.FireImmediately) {
observer.fire(value, newValue: value)
}
}
func removeObserver(_ observer: Observer<ValueType>) {
privateQueue.sync {
guard let index = self.observers.firstIndex(where: { observer === $0 }) else { return }
self.observers.remove(at: index)
}
}
}
复制代码
用法:使用Observable类型的数据,比如@Published var dataSource: Observable<Array<PersonModel>> = Observable([])
用到的地方
vm.dataSource.onSet { oldValue, newValue in
dataSource = newValue
}
复制代码
q:在内购弹出时出现自动返回到上一个页面
这个问题让我头疼了好久,因为使用SwiftUI的人不多,网上也找不到类似的问题,先看看问题
可惜的是当时忘记录屏了,我简单描述一下:
- 当前页面为页面A
- 进入到一个页面B
- 在页面B中需要查看某个服务需要开通vip,会跳转到页面C即VIP的页面
- 在VIP页面点击购买弹出了内购框,当内购框弹出时,页面自动返回到上一个页面即页面B,但弹出没有关闭
整个过程就是这样,不知道有没有遇到同样的问题的伙伴,可以说一下你们的解决方案
先看一下出现这个问题时当时实现的代码:
// NavigationView不一定是在VIP页面实现的,上面也有讲到,应用中只需要定义一次,
NavigationView {
Button {
isLoading = true
vm.buy(purchaseProductId: quarterly) { isSuccess in
print("购买" + (isSuccess ? "成功" : "失败"))
self.isLoading = false
}
MobClick.endEvent(quarterly)
} label: {
HStack {
Text("按季订阅 ¥\(vm.quarterlyPrice.value)/季")
.font(.system(size: 19))
.foregroundColor(.white)
.fontWeight(.heavy)
}
.frame(width: UIScreen.screenWidth - 30, height: 50)
}
.background(Color.black)
.cornerRadius(8)
}
复制代码
在点击“按季订阅”后,会连接appStore调起弹窗填入appid和密码,当弹出时,页面就会自动返回到上一个页面
问题讲清楚了,至于出现这个问题的原因暂时还不清楚,先讲一下我采用的解决方案
在页面需要购买时即需要调整到VIP的页面修改跳转方式,使用Modal的方式显示VIP订阅页面,使用over(isPresented: $isNavPush, content: { BuyView(type: "purchase") })
方式调整后,再调用购买时候就不会有这个问题了,不过如果你的页面上有按钮要跳转时(如购买协议等)就需要再在这个页面添加NavigationView来包裹页面内容。这样就可以使用NavigationLink
方式来跳转到新的页面,而VIP订阅页面则需要手动实现返回按钮。
q:部分页面跳转的时候会触发启动文件再次执行
这个暂时还不知道是什么原因,我登录后需要进入到首页,进入的方式是使用NavigationLink
,但是发现启动文件再次执行了。
q:页面出现ScrollView需要全屏展示即隐藏导航栏和状态栏
上面讲到VIP订阅页面是使用Modal的方式进入的,页面默认没有导航栏,但是页面使用了ScrollView,ScrollView有一个属性contentInsetAdjustmentBehavior
来对safeAreaInsets的一些调整,不过这个属性在SwiftUI中暂时并不支持,会导致默认没有导航栏的页面在页面向上活动的时候顶部会出现一个没有内容的导航栏,这对于一个没有导航栏的页面来说是很突兀的,导致体验差与需求不符的情况。在网上找了很久也没有找到一个好的解决方案。于是我使用了让页面整体向上偏移了一段距离(导航栏高度+状态栏高度)。这样页面就没有问题,但是我对这个解决方案并不满意,虽然目的达到了,但是总感觉有点旁门左道的感觉。
q:页面弹窗类似UIAlertController、加载中的提示动画
虽然现在使用SwiftUI来做项目的人还不多,但是像这种加载动画在网上还是可以找到很多,相对于Swift和oc实际使用起来会稍微麻烦一些。其实就是定义一个页面来展示加载中或UIAlertController样式的页面,然后加的页面上,这里还使用到了一个第三方依赖ToastSwiftUI
,当然这个也可以自己写,并不是很难。如果像我一样懒也可以直接使用
import SwiftUI
struct PurchasePop: View {
@State var isAnimating = false
@State var loadText: String = "请求数据中..."
var body: some View {
VStack {
Spacer()
Image("loading")
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0), anchor: .center)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
isAnimating = true
}
}
}
Spacer().frame(height: 20)
Text(loadText)
Spacer()
}
.background(Color.white)
.frame(width: 150, height: 150)
.cornerRadius(8)
}
}
复制代码
把这个组件定义到需要弹窗的页面上
.popup(isPresenting: $isLoading,overlayColor: Color.black.opacity(0.4), popup: PurchasePop(loadText: ""))
.popup(isPresenting: $isLoadingData,overlayColor: Color.black.opacity(0.4), popup: PurchasePop())
复制代码
.popup
就是ToastSwiftUI的方法。
q:键盘遮挡输入框问题
这个问题在Swift或OC中都可以使用IQKeyboardManager
来解决,而且用法也很简单。但是这个在SwiftUI中的兼容性并不好。
import SwiftUI
import Combine
struct AdaptsToKeyboard: ViewModifier {
@State var currentHeight: CGFloat = 0
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.bottom, self.currentHeight)
.onAppear(perform: {
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillShowNotification)
.merge(with: NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillChangeFrameNotification))
.compactMap { notification in
withAnimation(.easeOut(duration: 0.16)) {
notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
}
}
.map { rect in
rect.height - geometry.safeAreaInsets.bottom
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
NotificationCenter.Publisher(center: NotificationCenter.default, name: UIResponder.keyboardWillHideNotification)
.compactMap { notification in
CGFloat.zero
}
.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight))
})
}
}
}
extension View {
func adaptsToKeyboard() -> some View {
return modifier(AdaptsToKeyboard())
}
}
复制代码
可以使用以上述方法解决,不过这个方案对IQKeyboardManager有一点副作用,就是图片中框住的这一块没有了,如果能接受这样的副作用,这个方案也是可以了。
最近时间不是很充裕,项目中遇到的有些问题也忘记了,项目的问题会在后续给出解决方案(我解决的方案)