SwiftUI実践プロジェクトまとめ

序文

最近週末休みが始まったので、しばらく SwiftUI を学びたいと思っています。SwiftUI には以前から注目していましたが、体系的に学ぶ時間がありませんでした。

主な機能

  • 現在地をアップロードする
  • 友人を追加する
  • ディレクトリ電話にアクセスする
  • 地図表示位置決めトラック
  • アプリ内購入

テクノロジーの選択

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

copy新しい実装を取得できるように、別の場所を変更するだけで済み、他の場所を書き直す必要がないようにするには、これらの UI コンポーネントにコピー プロトコルを実装する必要があるのではないかとよく考えます。これにより、私たちの作業負荷が大幅に軽減されます。でも実際にはやったことがないんです。

しかし、SwiftUI はそのような問題を大幅に軽減しました。もちろん、上で述べた問題が解決されたわけではありません。上記の状況は依然として存在しますが、一部のレイアウトの問題については、Swift や OC と比較してコード量が大幅に削減されます。一方、SwiftUI は、classよりもstructを使用してページを定義し、メモリを節約するため、パフォーマンスが優れています。ただし、現在 SwiftUI には問題が非常に多く、実際のプロジェクトで使用することはお勧めできません。また、依然として更新が頻繁で、バージョンごとに新しい API がリリースされることもあれば、以前の API が廃止されることもありますが、互換性はあまり良くなく、バージョン管理が必要な API も多く、非常に手間がかかります。

モヤ

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

SnapKit

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

HandyJSON

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

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

代码实现

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

VStack

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

HStack

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

ZStack

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

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

画像.png

NavigationView

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

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

前述のように、VIP サブスクリプション ページはモーダルを使用して入力されます。このページにはデフォルトでナビゲーション バーがありませんが、ページには ScrollView が使用されます。ScrollView には、safeAreaInsets を調整するプロパティがありますが、このプロパティは当面 SwiftUI ではサポートされませcontentInsetAdjustmentBehaviorんこれにより、デフォルトでは、ナビゲーション バーのないページには、ページがアクティブなときに上部にコンテンツのないナビゲーション バーが表示されます。これは、ナビゲーション バーのないページでは非常に突然であり、エクスペリエンスが低下し、要件が一貫性がなくなります。インターネットを長い間検索しましたが、良い解決策が見つかりませんでした。そこで、ページ全体を一定の距離(ナビゲーションバーの高さ + ステータスバーの高さ)だけオフセットしていました。このようにページには問題はありませんが、この解決策には満足できず、目的は達成できましたが、常に少し異端的な印象を受けます。

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

.popupToastSwiftUIのメソッドです。

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 に若干の副作用、つまり絵の中の枠が消えてしまう副作用がありますが、それを許容できるのであればこの解決方法も可能です。

企業 WeChat スクリーンショット_6fcbf5ff-3d78-4eb5-863e-75aaf991e161.png

最近時間が足りず、プロジェクトで発生した問題点を忘れていることがあります。プロジェクトの問題点はフォローアップで解決します(私の解決策)

おすすめ

転載: juejin.im/post/7223357165682786363