Resumen de proyectos prácticos de SwiftUI

prefacio

Hace poco comencé un descanso de fin de semana y quiero aprender SwiftUI por un tiempo. He estado prestando atención a SwiftUI durante mucho tiempo, pero nunca he tenido tiempo de aprender de manera sistemática.

la función principal

  • Subir ubicación actual
  • añadir amigo
  • acceder al teléfono del directorio
  • Pista de posicionamiento de visualización de mapa
  • aplicación en la compra

Selección de tecnología

SwiftUI

Por un lado, es para aprender. Por otro lado, es más fácil escribir páginas que Swift u Objective-c. Aunque se puede usar xib y storybroad, todavía no es tan conciso como SwiftUI (la misma UI puede ser implementado de varias maneras, y la cantidad de código de SwiftUI es mínima).

Cuando antes solo conocía iOS, no pensaba que escribir páginas de iOS fuera complicado. Ahora, cuando escribo páginas de códigos de iOS, encuentro que este proceso es bastante engorroso y requiere escribir muchos códigos similares. Por ejemplo el siguiente código:

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

A menudo me pregunto si es necesario implementar un protocolo de copia para estos componentes de la interfaz de usuario, de modo que se copypueda adquirir una nueva implementación, solo necesitamos cambiar los diferentes lugares y no es necesario volver a escribir otros lugares. Esto puede reducir en gran medida nuestra carga de trabajo. Pero en realidad no lo he hecho.

Sin embargo, SwiftUI ha reducido en gran medida tales problemas. Por supuesto, no ha resuelto el problema que mencioné anteriormente. Aunque la situación anterior aún existe, reduce en gran medida la cantidad de código para algunos problemas de diseño en comparación con Swift y OC. . Por otro lado, SwiftUI usa struct para definir páginas para ahorrar memoria más que class , por lo que su rendimiento es mejor. Sin embargo, ahora hay bastantes problemas en SwiftUI y no se recomienda usarlo en proyectos reales. Además, sus actualizaciones siguen siendo muy frecuentes. Algunas nuevas API se lanzan en cada versión, y algunas API anteriores también se abandonan. La compatibilidad no es muy buena. Muchas API necesitan ser controladas por versión, lo cual es muy laborioso. . .

Moya

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

SnapKit

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

HandyJSON

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

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

代码实现

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

VStack

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

HStack

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

ZStack

HStack 表示纵向布局方式,如图所示,遇到这种场景用这个就对了 imagen.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()),设置相当于填充剩余空间。

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

imagen.png

NavigationView

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

imagen.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需要全屏展示即隐藏导航栏和状态栏

Como se mencionó anteriormente, la página de suscripción VIP se ingresa usando Modal. La página no tiene una barra de navegación de forma predeterminada, pero la página usa ScrollView. ScrollView tiene una propiedad contentInsetAdjustmentBehaviorpara ajustar safeAreaInsets, pero esta propiedad no es compatible con SwiftUI por el momento. , lo que provocará De forma predeterminada, una página sin barra de navegación tendrá una barra de navegación sin contenido en la parte superior cuando la página esté activa, lo que es muy brusco para una página sin barra de navegación, lo que genera una experiencia deficiente y requisitos inconsistentes. Busqué en Internet durante mucho tiempo y no pude encontrar una buena solución. Así que solía desplazar la página como un todo por una cierta distancia (altura de la barra de navegación + altura de la barra de estado). De esta manera no hay problema con la página, pero no estoy satisfecho con esta solución, aunque se logra el objetivo, siempre se siente un poco herético.

q: La ventana emergente de la página es similar a UIAlertController, y la animación del indicador de carga

Aunque no hay muchas personas que usen SwiftUI para hacer proyectos ahora, aún puede encontrar muchas animaciones de carga como esta en Internet. En comparación con Swift y oc, será un poco más problemático de usar en la práctica. De hecho, es para definir una página para mostrar la carga o la página de estilo UIAlertController, y luego agregarla a la página. Aquí también se usa una dependencia de terceros. Por supuesto, esto también puede escribirlo usted mismo, que ToastSwiftUIes no es difícil. Si eres flojo como yo, también puedes usarlo directamente

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

Defina este componente en la página que debe aparecer

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

.popupEs el método de ToastSwiftUI.

q: El problema de que el teclado bloquea el cuadro de entrada

Este problema se puede resolver en Swift u OC IQKeyboardManager, y el uso es muy simple. Pero esta compatibilidad en SwiftUI no es buena.

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

复制代码

El método anterior se puede usar para resolverlo, pero esta solución tiene un pequeño efecto secundario en IQKeyboardManager, es decir, la pieza enmarcada en la imagen desaparece. Si se puede aceptar tal efecto secundario, esta solución también es posible.

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

No hay suficiente tiempo recientemente, y algunos problemas encontrados en el proyecto se han olvidado. Los problemas del proyecto se resolverán en el seguimiento (mi solución)

Supongo que te gusta

Origin juejin.im/post/7223357165682786363
Recomendado
Clasificación