所有 App 的成功,都取決於使用者是否常用這個 App(使用者留存率高 High User Retention),而成功的使用者體驗 (UX) 和界面設計對留存率就非常重要了!在設計 App 的時候,我們需要確保使用者可以利用最小限度而直覺的操作,來達到他們的目的;而且,這操作最好是一個吸引又有趣的過程。這次的教學就是希望利用客製化 UI,來達到上述的目標。
記得從這裡下載我們的範例專案。
使用情境
在這次的範例之中,我們將會製作一個無限分頁滾動視圖 (Infinite Paging Scroll View)。就如上方的動畫一樣,這個 UI 零件適用於給使用者少數預設選項去選擇的情況。你會希望透過小範圍的螢幕來顯示這些選項,並有一個預設選項已經被選取。然後,只要簡單地點擊螢幕來選取新選項,並透過 MVC 架構來傳遞就可以了。那就讓我們開始吧!
無限滾動的錯覺
在我們開始實作專案之前,先討論一下無限滾動的錯覺是如何運作的。我們想要將滾動視圖設置為水平分頁,於陣列之中呈現數據。
圖一: 想像一下有四個元素,每個元素分別有不同的顏色及編號,我們想在滾動視圖的內容視圖之中,將各個元素分別以不同頁面來顯示。一般而言,我們會將內容的尺寸設計為滾動視圖的四倍寬,使每個元素都有一個自己的頁面。不幸的是,在這樣的情況下我們並不會獲得想要的效果,因為一旦我們將視圖滾動到第四個分頁,唯一能回到第一個分頁的方法,就是將內容偏移量 (content offset) 重設為 0,這樣確實可以回到起始的分頁沒錯,但就無法做到我們想要的漂亮分頁動畫。
圖二: 要解決這個問題,我們可以修改輸入數據,讓第一和最後一個元素在相反側各有一個複製,也就是說現在我們會用六個分頁來顯示四個元素。這樣我們就會建立到一個漂亮的分頁動畫,可以從元素四滾動到元素一(或是反方向從元素一滾動到元素四)。
圖三: 分頁動畫完成後,我們的滾動視圖會在內容視圖的尾端顯示第一個元素。這時候,我們就可以趁機設定內容偏移量,讓分頁從內容視圖前面相同的元素一顯示一樣的數據。這樣的切換對於使用者來說是無法查覺的。
請注意:在這個範例中,內容分頁僅向一個方向滾動,而上述的設置則展示了雙向的無限滾動。雖然這代表我們的範例中包含了一些不必要的程式碼,但我選擇使用允許雙向滾動的邏輯來實現它,這樣一來,有需要的話你就可以在自己的專案中實現它。
第一步:基礎設定
我們建立一個繼承自 UIView
的客製化類別 InfiniteScrollView
,並將背景顏色設為灰色來視覺化我們的結構 (第 20 行)。接著,我們定義兩個屬性:scrollView
和 tapView
。設置滾動視圖 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 行)。
接著,我們有 modifyDatasource
及 setupContentView
兩個方法。讓我們先看看第一個 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 內找到合適的地方來使用這個功能。謝謝你的閱讀!
Medium: https://medium.com/@timbeals。
LinkedIn: https://www.linkedin.com/in/tim-beals/
LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang