前言
上一篇我們總結的主要是VStack里面的東西,由他延伸到 @ViewBuilder, 接着我們上一篇總結的我們這篇內容主要說的是下面的幾點,在這些東西說完后我准備解析一下蘋果在SiwftUI文檔中說道的比較好玩的一個東西,具體的我們后面在看。這篇我們還是說我們關於SwiftUI的東西,再提一下Demo代碼我已經提交上Git了,目前Demo進度為一級頁面基本上結束,地圖點擊大頭針的添加也剛處理完,代碼有需要的小伙伴可以去Git看看,項目地址
1、View之間的跳轉(這里有個疑問需要幫忙!)
2、稍微復雜點View的布局思路和一些細節知識
3、SwiftUI循環輪播圖
這次總結的首頁的UI布局如下,我們下面一點點的解析:

界面跳轉的問題
正常的界面跳轉邏輯實現是比較簡單的,我們先看看這個很簡單的正常跳轉,再說說我們的問題:
NavigationView{
VStack{
List{
/// 開關按鈕
/// Toggle(isOn: $userData.showFavoritesOnly) {Text("Favorites only")}
ForEach(landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination:LandmarkDetail(landmark:landmark)
.environmentObject(self.userData),label:{
LandmarkRow(landmark: landmark)
})
}
}
}
.listStyle(PlainListStyle())
.navigationTitle("iPhone")
}
}
這是一個很普通的通過 NavigationView + NavigationLink 的界面跳轉,在蘋果給的 SwiftUI 的使用例子中就是這樣寫的,當然我們在正常的使用中這樣寫也沒啥問題,那我們界面跳轉的問題是什么呢?
如果你看了我們 Demo中的代碼,你就知道我們是采用 TabView 嵌套 NavigationView 的形式,在這樣的模式下似乎是存在問題的, 在 TabView+NavigationView 中你利用 NavigationLink 單擊沒法跳轉,只有長按的時候才能跳轉,這個問題拋出來,有懂得小伙伴希望能給我說一下,這個問題我也一直沒有解決!具體的我們Demo中可以看看“我的”頁面那個 List 的代碼,問題就在那里。要理解這點的麻煩也給我說說,感謝!
首頁布局
我們把首頁這個布局給解析一下,大概分了下面幾部分,我們再具體的說說:

我們看看最底層的代碼先:
NavigationView{
ScrollView(showsIndicators:false,content: {
/// Banner視圖
HomeBannerView()
.environmentObject(homeViewModel)
/// 服務列表
HomeServiceCircleView().frame(
width: homeViewModel.homeServiceCircleWidth,
height: homeViewModel.homeServiceCircleHeight)
.environmentObject(homeViewModel)
.offset(y: -5)
/// 滾動頭條
HomeCircleNewsView().frame(
width: homeViewModel.homeNewsCircleWidth,
height: homeViewModel.homeNewsCircleHeight)
.environmentObject(homeViewModel)
/// 四個按鈕
HomeButtonView().frame(
width: homeViewModel.homeButtonViewWidth,
height: homeViewModel.homeButtonViewHeight)
.offset(y: -5)
/// 服務列
HomeServiceListView().frame(
width: homeViewModel.homeServiceViewWidth,
height: homeViewModel.homeServiceViewHeight)
.environmentObject(homeViewModel)
/// 最美的風景
HomeSnapshotView().environmentObject(homeViewModel)
}).navigationTitle(title)
}
這部分的代碼沒有啥特別需要說明的,都比較簡單,可能是就是這個 environmentObject (我把它稱為環境變量)這個是需要特別說明的一個變量,從名字上可以看出,這個修飾符是針對全局環境的。通過它我們可以避免在初始 View 時創建 ObservableObject, 而是從環境中獲取 ObservableObject,像 @EnvironmentObject,@ObservedObject,@Binding 和 @States 這幾個關鍵字還是需要需要我們特別理解的。下面這篇我們博客園的同行總結的還是很精辟的。傳送門在這
下面是我們值得細說的一些點:
1、值得注意的 TabView + PageTabViewStyle
這是在iOS14中新出的一個值得我們注意的點,PageTabViewStyle 是14.0的新東西,但它的確能達到一個滿意的翻頁效果。和我們UIKit中的效果一樣。具體的代碼如下:
TabView(selection: $selection) {
/// 里面的具體內容,我們寫了三頁
ForEach(0..<3){
HomeServicePageView(pageIndex: $0)
.tag($0)
.environmentObject(homeViewModel)
}
}
/// PageTabViewStyle 14.0的新東西
.tabViewStyle(PageTabViewStyle())
.animation(.spring())
2、GeometryReader 它其實是有必要好好了解一下的。GeometryReader 的主要作用就是能夠獲取到父View建議的尺寸,這就是它的主要作用,要沒有它我們面臨的可能就是無休止的傳值了,SwiftUI 既然是聲明式的UI,按我的理解你就沒有辦法去獲取某一個視圖的父視圖之類的。不然怎么體現聲明這個點呢!
這個GeometryReader在前面第一期的時候我說過這個屬性。
/// A proxy for access to the size and coordinate space (for anchor resolution)
/// of the container view.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct GeometryProxy {
/// The size of the container view.
public var size: CGSize { get }
/// Resolves the value of `anchor` to the container view.
public subscript<T>(anchor: Anchor<T>) -> T { get }
/// The safe area inset of the container view.
public var safeAreaInsets: EdgeInsets { get }
/// Returns the container view's bounds rectangle, converted to a defined
/// coordinate space.
public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
}
* size 比較直觀,就是返回父View建議的尺寸
** subscript 可以讓我們獲取.leading,.top等等類似這樣的數據
*** safeAreaInsets 可以獲取安全區域的Insets
**** frame(in:) 要求傳入一個CoordinateSpace類型的參數,也就是坐標空間,可以是.local, .global 或者 .named(),其中 .named()可以自定義坐標空間。
有一個還得說明一下,GeometryReader 改變了它顯示內容的方式。在 iOS 13.5 中,內容放置方式為 .center。在 iOS 14.0 中則為:.topLeading。
3、再提一點關於上面說的滾動視圖,在UIKit中我們可以用UICollectionView搞定一切,但是在SwiftUI中沒有這個控件,我建議采用的方式是 ScrollView + HStack + VStack 的方式去實現,很多同行有說目前來看SwiftUI的List在數據量大的情況下性能不是特別好,采用ScrollView是個不錯的方式,而且也很容易構建出來,並不是說每一個Item的位置都需要你去計算,所以沒啥可以擔心的。
除了這個List,還要一個From我們也可以了解下,他們倆肉眼可見的區別 在選中這個點上的區別。
循環輪播實現
總結一下循環輪播怎么實現,采用的方案就是 HStack + Gesture + Timer 的方式,這三者就能實現一個自動循環滾動或者手動滾動的輪播。然后縮放的方式還是比較簡單的,我們采用改變下Image的frame的方式。
HStack 這沒啥可以具體說的,可以看代碼,注釋比較多,就不在這里累贅了。
Gesture 這個我們可以說說,它就是我們具體手勢的父類,像我們的單擊手勢和我們這里用到的拖拽手勢一樣。具體的我們會看下面的代碼,他們的區別就是像拖拽我們可以監控它的改變狀態,點擊或者雙擊、長按等我們可以添加事件等等。下面是拖拽的代碼:
/// 定義拖拽手勢
private var dragGesture: some Gesture{
DragGesture()
/// 拖動改變
.onChanged {
isAnimation = true
dragOffset = $0.translation.width
}
/// 結束
.onEnded {
dragOffset = .zero
/// 拖動右滑,偏移量增加,顯示 index 減少
if $0.translation.width > 50{
currentIndex -= 1
}
/// 拖動左滑,偏移量減少,顯示 index 增加
if $0.translation.width < -50{
currentIndex += 1
}
/// 防止越界
currentIndex = max(min(currentIndex, homeViewModel.homeBannerCount() - 1), 0)
}
}
再看看Timer,SwiftUI區別於我們UIKit的創建方式,SwiftUI對它進行了簡化,具體的創建如下:
/// SwiftUI對定時器的簡化,可以進去看看具體參數的定義 private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
它不像我們UIKit的需要我們綁定事件,那它的事件是怎么處理的呢?看看下面的代碼:
/// 對定時器的監聽
.onReceive(timer, perform: { _ in
currentIndex += 1
}
它的事件就是通過 onReceive 監聽處理的,所有通過 publish 創建的都是可以通過 onReceive 監聽的。那還有啥事通過 publish 創建的呢?我所用到的就是 NotificationCenter。
這樣基本上循環輪播的實現我們基本上都說清楚了,具體里面的一些實現細節代碼注釋寫的清清楚楚,還是仔細看看代碼結合里面的注釋來看,難度不是很大。首頁頂部自動循環輪播的代碼實現如下,代碼里有些注釋還是比較重要的,注意看注釋:
struct HomeBannerView: View {
@EnvironmentObject var homeViewModel: HomeViewModel
/// SwiftUI 對定時器的簡化,可以進去看看具體參數的定義
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
/// 拖拽的偏移量
@State var dragOffset: CGFloat = .zero
/// 當前顯示的位置索引,
/// 這是實際數據中的1就是數據沒有被處理之前的0位置的圖片
/// 所以這里默認從1開始
@State var currentIndex: Int = 1
/// 是否需要動畫
@State var isAnimation: Bool = true
let spacing: CGFloat = 10
var body: some View {
/// 單個子視圖偏移量 = 單個視圖寬度 + 視圖的間距
let currentOffset = CGFloat(currentIndex) * (homeViewModel.homeBannerWidth + spacing)
/// GeometryReader 改變了它顯示內容的方式。在 iOS 13.5 中,內容放置方式為 .center。在 iOS 14.0 中則為:.topLeading
GeometryReader(content: { geometry in
HStack(spacing: spacing){
ForEach(0..<homeViewModel.homeBannerCount()){
/*
如果想自定義Image大小,可以添加frame
clipped()相當於UIKit里的clipsToBounds,
與aspectRatio(contentMode: .fill)搭配使用。
注意:frame 要放在resizable后面,否則報錯,
如果需求裁剪,需要放在aspectRatio后面,
clipped()前面,否則frame失效 */
Image(homeViewModel.bannerImage($0)).resizable()
/// 自己嘗試一下.fill和.fit
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width,
height: $0 == currentIndex ? geometry.size.height:geometry.size.height*0.8 )
.clipped() /// 裁減
.cornerRadius(10)
}
}.frame(width:geometry.size.width,
height:geometry.size.height,alignment:.leading)
.offset(x: dragOffset - currentOffset)
.gesture(dragGesture)
/// 綁定是否需要動畫
.animation(isAnimation ?.spring():.none)
/// 監聽當前索引的變化,最開始初始化為0是不監聽的,
.onChange(of: currentIndex, perform: { value in
isAnimation = true
/// 第一張的時候
if value == 0 {
isAnimation.toggle()
currentIndex = homeViewModel.homeBannerCount() - 2
/// 最后一張的時候currentIndex設置為1關閉動畫
}else if value == homeViewModel.homeBannerCount() - 1 {
isAnimation.toggle()
currentIndex = 1
}
})
/// 對定時器的監聽
.onReceive(timer, perform: { _ in
currentIndex += 1
})
}).frame(width: homeViewModel.homeBannerWidth,
height: homeViewModel.homeBannerHeight)
}
}
// MARK: -
extension HomeBannerView{
/// 定義拖拽手勢
private var dragGesture: some Gesture{
DragGesture()
/// 拖動改變
.onChanged {
isAnimation = true
dragOffset = $0.translation.width
}
/// 結束
.onEnded {
dragOffset = .zero
/// 拖動右滑,偏移量增加,顯示 index 減少
if $0.translation.width > 50{
currentIndex -= 1
}
/// 拖動左滑,偏移量減少,顯示 index 增加
if $0.translation.width < -50{
currentIndex += 1
}
/// 防止越界
currentIndex = max(min(currentIndex, homeViewModel.homeBannerCount() - 1), 0)
}
}
}
參考文章:
理解SwiftUI關鍵字 State Binding ObservesOgiect EnvironmentObje
