iOS App 程式開發

實作無限分頁滾動視圖 (Scroll View) 為使用者帶來更完美的體驗和設計

在這次的範例之中,我們將會製作一個無限分頁滾動視圖 (Infinite Paging Scroll View)。
實作無限分頁滾動視圖 (Scroll View) 為使用者帶來更完美的體驗和設計
實作無限分頁滾動視圖 (Scroll View) 為使用者帶來更完美的體驗和設計
In: iOS App 程式開發, Swift 程式語言, UI
本篇原文(標題:Custom UI Master Class: Infinite Paging Scroll View )刊登於作者 Medium,由 Tim Beals 所著,並授權翻譯及轉載。

所有 App 的成功,都取決於使用者是否常用這個 App(使用者留存率高 High User Retention),而成功的使用者體驗 (UX) 和界面設計對留存率就非常重要了!在設計 App 的時候,我們需要確保使用者可以利用最小限度而直覺的操作,來達到他們的目的;而且,這操作最好是一個吸引又有趣的過程。這次的教學就是希望利用客製化 UI,來達到上述的目標。

記得從這裡下載我們的範例專案。

使用情境

在這次的範例之中,我們將會製作一個無限分頁滾動視圖 (Infinite Paging Scroll View)。就如上方的動畫一樣,這個 UI 零件適用於給使用者少數預設選項去選擇的情況。你會希望透過小範圍的螢幕來顯示這些選項,並有一個預設選項已經被選取。然後,只要簡單地點擊螢幕來選取新選項,並透過 MVC 架構來傳遞就可以了。那就讓我們開始吧!

無限滾動的錯覺

在我們開始實作專案之前,先討論一下無限滾動的錯覺是如何運作的。我們想要將滾動視圖設置為水平分頁,於陣列之中呈現數據。

圖一: 想像一下有四個元素,每個元素分別有不同的顏色及編號,我們想在滾動視圖的內容視圖之中,將各個元素分別以不同頁面來顯示。一般而言,我們會將內容的尺寸設計為滾動視圖的四倍寬,使每個元素都有一個自己的頁面。不幸的是,在這樣的情況下我們並不會獲得想要的效果,因為一旦我們將視圖滾動到第四個分頁,唯一能回到第一個分頁的方法,就是將內容偏移量 (content offset) 重設為 0,這樣確實可以回到起始的分頁沒錯,但就無法做到我們想要的漂亮分頁動畫。

圖二: 要解決這個問題,我們可以修改輸入數據,讓第一和最後一個元素在相反側各有一個複製,也就是說現在我們會用六個分頁來顯示四個元素。這樣我們就會建立到一個漂亮的分頁動畫,可以從元素四滾動到元素一(或是反方向從元素一滾動到元素四)。

圖三: 分頁動畫完成後,我們的滾動視圖會在內容視圖的尾端顯示第一個元素。這時候,我們就可以趁機設定內容偏移量,讓分頁從內容視圖前面相同的元素一顯示一樣的數據。這樣的切換對於使用者來說是無法查覺的。

請注意:在這個範例中,內容分頁僅向一個方向滾動,而上述的設置則展示了雙向的無限滾動。雖然這代表我們的範例中包含了一些不必要的程式碼,但我選擇使用允許雙向滾動的邏輯來實現它,這樣一來,有需要的話你就可以在自己的專案中實現它。

第一步:基礎設定

我們建立一個繼承自 UIView 的客製化類別 InfiniteScrollView,並將背景顏色設為灰色來視覺化我們的結構 (第 20 行)。接著,我們定義兩個屬性:scrollViewtapView。設置滾動視圖 scrollView 時 (第 3-9 行),我們要刪除滾動指示器 (scroll indicator),並啟用分頁;而點擊視圖 tapView 會被標記為 lazy 變數,以便我們稍後添加點按手勢。目前,點擊視圖僅設定為透明 (第 11-15 行)。我們這樣佈置這些子視圖 (第 27-36 行):滾動視圖大小設置為視圖的一半,然後讓 tapView 填滿視圖的邊界,並置於滾動視圖的頂部。現在,使用者可以與較大的視圖進行互動,而滾動則會在較小的視圖中運作。

class InfiniteScrollView: UIView {
    
    let scrollView: UIScrollView = {
        let scroll = UIScrollView()
        scroll.backgroundColor = UIColor.red
        scroll.showsHorizontalScrollIndicator = false
        scroll.isPagingEnabled = true
        return scroll
    }()
    
    lazy var tapView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.clear
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.backgroundColor = UIColor.gray
        
        setupSubviews()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setupSubviews() {
        scrollView.frame = CGRect(x: (self.bounds.width / 2),
                                  y: 0,
                                  width: (self.bounds.width / 2),
                                  height: self.bounds.height)
        self.addSubview(scrollView)
        
        tapView.frame = self.bounds
        self.addSubview(tapView)
    }
}
//In the View Controller
self.scrollOptionsView = InfiniteScrollView(frame: CGRect(x: 0, y: 300, width: self.view.bounds.width, height: 40))        
self.view.addSubview(scrollOptionsView)

我們用 frame 初始化了 InfiniteScrollView,並將它加入視圖控制器的子視圖階層中後,就會看到類似這樣的東西:

第二步:更改 Datasource 及滾動視圖佈局

下一步,我們要將數據加入到 infiniteScrollView 之中,並在加入到內容視圖的標籤中顯示元素。為了達到這個目的,我們需要創建兩個屬性,第一個是公開屬性 datasource,用來接收字串陣列 (第 3-7 行),以及另一個私有屬性 _datasource,這是輸入 (input) 更改後的版本,這麼一來我們就能夠如同上一節討論一樣,將第一和最後一個元素加到陣列的對側末端 (第 9-13 行)。

接著,我們有 modifyDatasourcesetupContentView 兩個方法。讓我們先看看第一個 modifyDatasource 方法 (第 15-25 行),請注意這個方法是由 datasource 內 didSet 屬性觀察器所呼叫的,所以我們可以確保每當數據改變時,客製化視圖都會被更新。我們把 datasource 綁定到 tempInput 變數,以確認 datasource 是否為 nil,同時也確認 count 數值是否大於 2 (第 16 行)。如果不符合這些條件,很明顯我們就沒有足夠的數據來達成滾動效果。在這種情況下,我們的方法會回傳未設置的子視圖。假設數據輸入和數量都正確,我們就可以更改 datasource,我們將第一及最後一個元素放入到元組 (tuple) 之中,就可以完成這個步驟了 (第 18 行)。強制解析這些數值是安全的,因為我們在前一個步驟已經檢查了這些索引中是否有值。然後,我們將第一個元素放入 tempInput 陣列尾端,並將最後一個元素插入陣列開頭 (第 17-18 行)。為了展示所需,我們可以加入 print 指令 (第 22 行),並用更改過的數據來設置私有屬性 _datasource

讓我們繼續設計,讓 _datasource 中的屬性觀察器呼叫 setupContentView() (第 10-12 行)。這邊,我們移除所有可能先前設定過的子視圖 (第 31-34 行)。接著,我們使用可選綁定 (optional binding) 來解析 _datasource,並設定內容尺寸。就我們的情況而言,我們希望以水平的方式滾動分頁,所以內容的高度將會跟滾動視圖的高度一致,而寬度會等於滾動視圖寬度乘以 _datasource 之中元素的數量 (第 36-38 行)。為了將標籤加入到正確位置,我們以循環的方式來創建標籤,並使用 i 值來計算新的標籤原點。在將標籤添加為 scrollView 的子視圖前 (第 41-50 行),我們先使用同樣的 i 值從 datasource 下標相應字串,並將它設置為我們的標籤文字。當所有標籤都加上了適當的文字後,我們就可以設置內容偏移量來顯示第一個元素 (第 51-52 行)。

class InfiniteScrollView: UIView {
   
    var datasource: [String]? {
        didSet {
            modifyDatasource()
        }
    }
    
    private var _datasource: [String]? {
        didSet {
            setupContentView()
        }
    }
    
    private func modifyDatasource() {
       guard var tempInput = datasource, tempInput.count >= 2 else { 
           return 
        }
        
        let firstLast = (tempInput.first!, tempInput.last!)
        tempInput.append(firstLast.0)
        tempInput.insert(firstLast.1, at: 0)
        
        print("_datasource set to: \(tempInput)")
        
        self._datasource = tempInput
    }
    
    private func setupContentView() {
        
       let subviews = scrollView.subviews
        for subview in subviews {
            subview.removeFromSuperview()
        }
        
        guard let data = _datasource else { return }

        self.scrollView.contentSize = CGSize(width: scrollView.frame.size.width * CGFloat(data.count),
                                             height: scrollView.frame.size.height)

        for i in 0..<data.count {
            var frame = CGRect()
            frame.origin.x = scrollView.frame.size.width * CGFloat(i)
            frame.origin.y = 0
            frame.size = scrollView.frame.size

            let label = UILabel(frame: frame)
            label.text = data[i]
            self.scrollView.addSubview(label)
        }
        let index = 1
        scrollView.contentOffset = CGPoint(x: (scrollView.frame.width * CGFloat(index)), y: 0)
    }

}
self.infiniteScrollView.datasource = ["option one", "option two", "option three", "option four"]

/*prints: 
_datasource set to: ["option four", "option one", "option two", "option three", "option four", "option one"]
*/

為了看看成果,我們可以加入一個 datasource 到視圖控制器的 infiniteScrollView物件中,並執行 App。終端機所印出的輸出,顯示了更改過的 _datasource 中,第一和最後一個元素都被正確地加入了 (第 3-5 行)。

第三步:加入點擊手勢與分頁邏輯

雖然數據已經加入到滾動視圖之中,但是我們尚未能夠滾動分頁。為了達到這個效果,我們使用選擇器 (selector) 方法 didReceiveTap(sender:),將點擊手勢加入到 tapView 屬性中 (第 6-7 行)。這個方法 (第 19-28 行) 將滾動視圖的寬度加到目前內容的偏移量 x 值,來創建我們想要顯示的下一個矩形 (nextRect)。這樣,我們就能從 scrollRectToVisible 方法 (第 27 行) 獲得分頁動畫了!

當到達 datasource 末端時,我們就需要添加邏輯來設置內容偏移量,這個邏輯應在滾動動畫一完成後立刻被呼叫。如果我們設定客製化類別為滾動視圖的 delegate,我們就能夠加入覆寫方法 scrollViewDidScroll,每當滾動動畫完成時都會被呼叫。我們在初始化方法 (第 15 行) 中設定這個委派,然後在 scrollViewDidScroll 中,我們確認可查看內容的位置是否超出了 datasource 的邊界,並使用 contentOffset (第 33-41 行) 適當地重置。

現在建置並執行 App,我們應該能夠看到想要的分頁效果。

class InfiniteScrollView: UIView {

    lazy var tapView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.clear
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didReceiveTap(sender:)))
        view.addGestureRecognizer(tapGesture)
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.backgroundColor = UIColor.gray
        scrollView.delegate = self
        setupSubviews()
    }
  
    @objc
    func didReceiveTap(sender: UITapGestureRecognizer) {
        let x = scrollView.contentOffset.x
        let nextRect = CGRect(x: x + scrollView.frame.width,
                              y: 0,
                              width: scrollView.frame.width,
                              height: scrollView.frame.height)

        scrollView.scrollRectToVisible(nextRect, animated: true)
    }
}

extension InfiniteScrollView: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard _datasource != nil else { return }
        let x = scrollView.contentOffset.x
        if x >=  scrollView.frame.size.width * CGFloat(_datasource!.count - 1) {
            self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width , y: 0)
        } else if x < scrollView.frame.width {
            self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width * CGFloat(_datasource!.count - 2), y: 0)
        }
    }
}

第四步:創建委派來傳遞已選取的選項

差不多完成了!InfiniteScrollView 確實達到了我們想要的效果,不過還差一步,我們想要將所選取到的選項回傳給視圖控制器,最好的方法就是使用委派模式。

首先,我們宣告 InfiniteScrollViewDelegate 協定,這協定帶有一個單一方法,將選項以輸入參數 optionChanged(to option:) 傳遞 (第 1-3 行)。然後,創建一個可選協定類型的委派屬性 (第 13 行),再創建一個 selectedOption 字串屬性來透過 didSet屬性觀察器呼叫委派方法 (第 7-11 行)。現在每當我們設定 selectedOption,委派就會收到選擇。我們將會在兩個地方設置此屬性,一旦我們佈局了內容視圖,就可以改變內容的偏移量來顯示第一個選項。而現在我們也設定了第一個選項為 selectedOption (第 39 行)。

最後,我們可以在使用者點擊時 ── 也就是分頁動畫剛開始時 ── 設定選擇的選項。我們使用 guard 來解析 datasource、計算下一個選項的索引值、並使用它來下標 datasource,將字串的值指派給 selectedOption (第 47-51 行)。

protocol InfiniteScrollViewDelegate {
   func optionChanged(to option: String)
}

class InfiniteScrollView: UIView {
    
   var selectedOption: String! {
       didSet {
           self.delegate?.optionChanged(to: selectedOption)
       }
   }

   var delegate: InfiniteScrollViewDelegate?
    
   private func setupContentView() {

        let subviews = scrollView.subviews
        for subview in subviews {
            subview.removeFromSuperview()
        }

        guard let data = _datasource else { return }

        self.scrollView.contentSize = CGSize(width: scrollView.frame.size.width * CGFloat(data.count),
                                             height: scrollView.frame.size.height)

        for i in 0..<data.count {
            var frame = CGRect()
            frame.origin.x = scrollView.frame.size.width * CGFloat(i)
            frame.origin.y = 0
            frame.size = scrollView.frame.size

            let label = UILabel(frame: frame)
            label.text = data[i]
            self.scrollView.addSubview(label)
        }
        let index = 1
        scrollView.contentOffset = CGPoint(x: (scrollView.frame.width * CGFloat(index)), y: 0)
        self.selectedOption = data[index]
    }

    @objc
    func didReceiveTap(sender: UITapGestureRecognizer) {
       guard let data = datasource else { return }

       var index = Int(scrollView.contentOffset.x / scrollView.frame.width)
       index = index < data.count ? index : 0
       self.selectedOption = data[index]

        let x = scrollView.contentOffset.x
        let nextRect = CGRect(x: x + scrollView.frame.width,
                              y: 0,
                              width: scrollView.frame.width,
                              height: scrollView.frame.height)

        scrollView.scrollRectToVisible(nextRect, animated: true)
    }
}
//In the VC
self.infiniteScrollView.delegate = self

extension ViewController: InfiniteScrollViewDelegate {

    func optionChanged(to option: String) {
        print("delegate called with option: \(option)")
    }

}

在視圖控制器中,我們可以採用 InfiniteScrollViewDelegate 協定,並處理我們想要的傳入選項。然後,我們將視圖控制器自己設置為委派對象。讓我們建置並執行 App,我們應該能看到選項被發送到視圖控制器之中!

總結

大概就是這樣了!為了讓 UI 元件依照我們想要的方式運作,我們必須實現相當多的邏輯,但我們現在有了一些可高度重用的東西了。如果我們使用框架初始化 InfiniteScrollView,設置委派並傳遞我們想要滾動的選項陣列,你會發現我們只需三行簡單的程式碼,就可以建構到一個組件了!你也可以探索更多有趣的變化,加入滑動手勢或進行雙向滾動。你還可以研究刪除 tapView、並在 tableView 通過選擇單元格觸發點擊邏輯。希望你會看到更多有趣的組合,並在你的 App 內找到合適的地方來使用這個功能。謝謝你的閱讀!

本篇原文(標題:Custom UI Master Class: Infinite Paging Scroll View )刊登於作者 Medium,由 Tim Beals 所著,並授權翻譯及轉載。
作者簡介:Tim Beals 是一名 iOS 開發人員,擁有專業指導背景。他是軟件出版商 Roobi Creative Enterprises 的創始人。
Medium: https://medium.com/@timbeals
LinkedIn: https://www.linkedin.com/in/tim-beals/
譯者簡介:HengJay,iOS 初學者,閒暇之餘習慣透過線上 MOOC 資源學習新的技術,喜歡 Swift 平易近人的語法也喜歡狗狗,目前參與生醫領域相關應用的 App 開發,希望分享文章的同時也能持續精進自己的基礎。

LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang

作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。