Summary of SwiftUI practical projects

foreword

I recently started a weekend break, and I want to learn SwiftUI for a while. I have been paying attention to SwiftUI for a long time, but I have never had time to learn systematically.

The main function

  • Upload current location
  • add friend
  • access directory phone
  • Map display positioning track
  • in-app purchase

Technology selection

SwiftUI

On the one hand, it is for learning. On the other hand, it is easier to write pages than Swift or Objective-c. Although xib and storybroad can be used, it is still not as concise as SwiftUI (the same UI can be implemented in several ways, and the code of SwiftUI amount is minimal).

When I only knew about iOS before, I didn’t think that writing iOS pages was complicated. Now when I write iOS code pages, I find that this process is quite cumbersome and requires writing a lot of similar codes. For example the following code:

    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)
    }
复制代码

I often wonder whether it is necessary to implement a copy protocol for these UI components, so that copya new implementation can be acquired, we only need to change the different places, and there is no need to rewrite other places. This can greatly reduce our workload. But in fact I haven't done it.

However, SwiftUI has greatly reduced such problems. Of course, it has not solved the problem I mentioned above. Although the above situation still exists, it greatly reduces the amount of code for some layout problems compared with Swift and OC. . On the other hand, SwiftUI uses struct to define pages to save memory more than class , so its performance is better. However, there are quite a lot of problems in SwiftUI now, and it is not recommended to use it in actual projects. Moreover, its updates are still very frequent. Some new APIs are released in each version, and some previous APIs are also abandoned. The compatibility is not very good. Many APIs need to be version controlled, which is very laborious. . .

Moya

Moya做网络请求还是很香的,Moya有Moya/RxSwiftMoya/ReactiveSwiftMoya/CombineMoya等可以使用的版本,每一个都使用不同的技术实现感兴趣的朋友可以自己了解一下,我这里选择的是Moya/Combine

SnapKit

如果所有的功能都使用SwiftUI实现还是很难的,不过好在可以混编,所以SnapKit就有必要了。

HandyJSON

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

--- 万事俱备,开始我们的代码之路吧 ---

代码实现

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

VStack

VStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了 image.png

HStack

HStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了 image.png

ZStack

HStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了 image.png

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

大家可以看看下面这个图辅助理解

image.png

NavigationView

NavigationView相当于UINavigationViewController,项目中只需要一个,其他的页面会自动继承NavigationView,如果项目中出现多个NavigationView,就会出现多个导航栏,表现显示如下

image.png

出现这个问题的原因是在项目中使用了多个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需要全屏展示即隐藏导航栏和状态栏

As mentioned above, the VIP subscription page is entered using Modal. The page does not have a navigation bar by default, but the page uses ScrollView. ScrollView has a property contentInsetAdjustmentBehaviorto adjust safeAreaInsets, but this property is not supported in SwiftUI for the time being, which will cause By default, a page without a navigation bar will have a navigation bar with no content at the top when the page is active, which is very abrupt for a page without a navigation bar, resulting in poor experience and inconsistent requirements. I searched the Internet for a long time and couldn't find a good solution. So I used to offset the page as a whole by a certain distance (navigation bar height + status bar height). In this way, there is no problem with the page, but I am not satisfied with this solution. Although the goal is achieved, it always feels a bit heretical.

q: The page pop-up window is similar to UIAlertController, and the loading prompt animation

Although there are not many people who use SwiftUI to do projects now, you can still find a lot of loading animations like this on the Internet. Compared with Swift and oc, it will be a little more troublesome to use in practice. In fact, it is to define a page to display the loading or UIAlertController-style page, and then add it to the page. A third-party dependency is also used here. Of course, ToastSwiftUIthis can also be written by yourself, which is not difficult. If you are lazy like me, you can also use it directly

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)
    }
}
复制代码

Define this component to the page that needs to pop up

.popup(isPresenting: $isLoading,overlayColor: Color.black.opacity(0.4), popup: PurchasePop(loadText: ""))
.popup(isPresenting: $isLoadingData,overlayColor: Color.black.opacity(0.4), popup: PurchasePop())
复制代码

.popupIt is the method of ToastSwiftUI.

q: The problem that the keyboard blocks the input box

This problem can be solved in Swift or OC IQKeyboardManager, and the usage is very simple. But this compatibility in SwiftUI is not good.

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())
    }
}

复制代码

The above method can be used to solve it, but this solution has a little side effect on IQKeyboardManager, that is, the framed piece in the picture is gone. If such a side effect can be accepted, this solution is also possible.

Corporate WeChat screenshot_6fcbf5ff-3d78-4eb5-863e-75aaf991e161.png

There is not enough time recently, and some problems encountered in the project have been forgotten. The problems of the project will be solved in the follow-up (my solution)

Guess you like

Origin juejin.im/post/7223357165682786363
Recommended