解析SwiftUI佈局細節(二)迴圈輪播+複雜佈局

zxRisingSun發表於2021-01-05

 

前言


 

      上一篇我們總結的主要是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之GeometryReader

      理解SwiftUI關鍵字 State  Binding ObservesOgiect EnvironmentObje 

      SwiftUI 自定義實現旋轉木馬輪播效果

 

相關文章