• SwiftUI实战项目总结

前言

最近开端双休了,有点时刻就想学习一下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/RxSwiftMoya/ReactiveSwiftMoya/CombineMoya等能够运用的版本,每一个都运用不同的技术完成感兴趣的朋友能够自己了解一下,我这儿选择的是Moya/Combine

SnapKit

假如一切的功能都运用SwiftUI完成仍是很难的,不过好在能够混编,所以SnapKit就有必要了。

HandyJSON

这个就不多介绍了,很好用的一个json转模型东西

— 万事俱备,开端咱们的代码之路吧 —

代码完成

首先有必要介绍一下常用的组件

VStack

VStack 表明纵向布局办法,如图所示,遇到这种场景用这个就对了

SwiftUI实战项目总结

HStack

HStack 表明纵向布局办法,如图所示,遇到这种场景用这个就对了

SwiftUI实战项目总结

ZStack

HStack 表明纵向布局办法,如图所示,遇到这种场景用这个就对了

SwiftUI实战项目总结

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()),设置相当于填充剩下空间。

咱们能够看看下面这个图辅佐理解

SwiftUI实战项目总结

NavigationView

NavigationView相当于UINavigationViewController,项目中只需求一个,其他的页面会主动承继NavigationView,假如项目中呈现多个NavigationView,就会呈现多个导航栏,体现显示如下

SwiftUI实战项目总结

呈现这个问题的原因是在项目中运用了多个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的人不多,网上也找不到相似的问题,先看看问题
可惜的是其时忘记录屏了,我简略描绘一下:

  1. 其时页面为页面A
  2. 进入到一个页面B
  3. 在页面B中需求检查某个服务需求开通vip,会跳转到页面C即VIP的页面
  4. 在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有一点副作用,便是图片中框住的这一块没有了,假如能承受这样的副作用,这个计划也是能够了。

SwiftUI实战项目总结

最近时刻不是很充裕,项目中遇到的有些问题也忘记了,项意图问题会在后续给出处理计划(我处理的计划)