序文
最近週末休みが始まったので、しばらく 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/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 サブスクリプション ページはモーダルを使用して入力されます。このページにはデフォルトでナビゲーション バーがありませんが、ページには 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())
复制代码
.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 に若干の副作用、つまり絵の中の枠が消えてしまう副作用がありますが、それを許容できるのであればこの解決方法も可能です。
最近時間が足りず、プロジェクトで発生した問題点を忘れていることがあります。プロジェクトの問題点はフォローアップで解決します(私の解決策)