iOS| #24 | 搭配動畫的水平滑動底線按鈕與頁面控制

Tommy
彼得潘的 Swift iOS / Flutter App 開發教室
16 min readFeb 1, 2022

在許多App中可以看到水平滑動切換頁面的功能,並且帶有絲滑細緻的動畫效果,這次就要來實作如此beautiful的玩意。

Photo by Marten Bjork on Unsplash

技術應用

  • StackView
  • ScrollView
  • Programing Autolayout

成果展示

UI畫面

功能解說&程式解說

  • 事前準備 — UI畫面
    首先我們要先將UI畫面拉好,後續會搭配程式調整Autolayout。

拉分頁要使用的UIButton

建立StackView,容納UIButton

建立StackView的Constraint

新增一個UIView,我們會將它當成底線。同時也設定BackgroundColor與Button相同。

建立UIView的Constraint,先將他放在Page1的底下(預設進畫面就是第一頁)

拉好@IBOutlet,後續會說明用程式控制點擊Button對應的換頁效果

  • 點擊Button產生動畫效果

override func viewDidLoad()添加Event至UIButton

let buttons = buttonStackView.subviewsfor button in buttons {let uibutton = button as! UIButtonuibutton.addTarget(self, action: #selector(changePage), for: .touchUpInside)}

取出buttonStackView的subviews

let buttons = buttonStackView.subviews

將subview轉型為UIButton。
這邊都是我們自己控制的,subview一定都是UIButton,所以使用as!。

let uibutton = button as! UIButton

添加Event到button中

uibutton.addTarget(self, action: #selector(changePage), for: .touchUpInside)

實作點擊Button後要call的method

@objc func changePage(sender: UIButton){//先關閉underlineViewWidthConstraint.isActive = falseunderlineViewCenterXConstraint.isActive = falseunderlineViewTopConstraint.isActive = false//改值underlineViewWidthConstraint = underlineView.widthAnchor.constraint(equalTo: sender.widthAnchor)underlineViewCenterXConstraint = underlineView.centerXAnchor.constraint(equalTo: sender.centerXAnchor)underlineViewTopConstraint = underlineView.topAnchor.constraint(equalTo: sender.bottomAnchor)//再ActiveunderlineViewWidthConstraint.isActive = trueunderlineViewCenterXConstraint.isActive = trueunderlineViewTopConstraint.isActive = trueUIViewPropertyAnimator(duration: 0.5, curve: .easeInOut) {self.view.layoutIfNeeded()}.startAnimation()}

因為我們後續要調整Constraint,所以要先停止運行。(若不做這動作的話會導致Constraint發生衝突而UI畫面無法照我們調整的樣子顯示)

//先關閉underlineViewWidthConstraint.isActive = falseunderlineViewCenterXConstraint.isActive = falseunderlineViewTopConstraint.isActive = false

根據sender(點擊的按鈕)調整underlineView的Constraint

underlineViewWidthConstraint = underlineView.widthAnchor.constraint(equalTo: sender.widthAnchor)underlineViewCenterXConstraint = underlineView.centerXAnchor.constraint(equalTo: sender.centerXAnchor)underlineViewTopConstraint = underlineView.topAnchor.constraint(equalTo: sender.bottomAnchor)

啟動Constraint

underlineViewWidthConstraint.isActive = trueunderlineViewCenterXConstraint.isActive = trueunderlineViewTopConstraint.isActive = true

使用UIViewPropertyAnimator將更新UI畫面加上動畫效果
duration:運行時間
curve:顯示效果(easeOut:緩出、easeIn:緩進、easeInOut:緩出與緩進、linear:線性移動)

UIViewPropertyAnimator(duration: 0.5, curve: .easeInOut) {self.view.layoutIfNeeded()}.startAnimation()
  • 滑動頁面換頁

建立ScrollView,且上下左右距離設為0

建立ContainerView,此時還會附帶一個ViewController。
把他們加入到ScrollView中。

建立StackView把ContainerView裝進來

注意:方向為水平橫移

新增UILabel比較好辨識在第幾頁

同時選取StackView與Content Layout Guide,並且設定彼此對齊

選取ContainerView與Frame Layout Guide,設定寬高相同

這樣一頁就完成了

接下來複製並且原地貼上另外外兩個ContainerView

新增兩個ViewController,並且分別從兩個ContainerView拉Segue(選擇Embed)

重複前面的動作,選取ContainerView與Frame Layout Guide,設定寬高相同。完成以後會長這樣(參考下圖)

設定ScrollView屬性Paging Enabled為true

  • 同步滑動頁面與點擊Button

首先要來調整上述在override func viewDidLoad()添加Event至UIButton的程式

調整前:

let buttons = buttonStackView.subviewsfor button in buttons {let uibutton = button as! UIButtonuibutton.addTarget(self, action: #selector(changePage), for: .touchUpInside)}

調整後:

for (index,button) in buttons.enumerated() {let uibutton = button as! UIButtonuibutton.tag = indexuibutton.addTarget(self, action: #selector(changePage), for: .touchUpInside)}

差異點如下說明:

使用Array的methodenumerated()搭配for-in loop 得出在stackView中該筆button的index與物件(在這邊可將index想像是當前頁面的序號)

for (index,button) in buttons.enumerated() { }

將index傳遞給uibutton的屬性tag,後續當點擊UIButton時會利用tag切換頁面

uibutton.tag = index

宣告變數width負責紀錄裝置頁面的寬度

func viewDidLayoutSubviews()賦予width值,因為在這邊才能取得UI元件正確的位置與寬高。

override func viewDidLayoutSubviews() {super.viewDidLayoutSubviews()width = view.bounds.width}

拉ScrollView的@IBOutlet

遵從UIScrollViewDelegate

實作func scrollViewWillEndDragging(_ scrollView: , withVelocity velocity: , targetContentOffset: ),如此一來當ScrollView被滑動時我們就可以知道他會落在第幾頁,並且同步至UIButton。
(備註:scrollViewWillEndDragging詳細說明可以參考iOS| #21 。)

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {guard let width = width else { return }//targetContentOffset.pointee.x 除以 width一定會剛好,因為有paging enabled的關係let currentPage = Int(targetContentOffset.pointee.x / width)let buttons = buttonStackView.subviewslet uibutton = buttons[currentPage] as! UIButtonsetButtonConstraint(button: uibutton)}

利用 targetContentOffset.pointee.x 除以 width 可以得到滑動ScrollView後的正確頁數。在這邊因為有啟動paging enabled,經過測試過後argetContentOffset.pointee.x會得出width的倍數,所以可以放心的計算。

let currentPage = Int(targetContentOffset.pointee.x / width)

得出頁數以後我們要做的事情就是將UIButton的底線(underlineView)移動到正確的位置上,在這邊我們先取得位於底線上方的UIButton元件。

let buttons = buttonStackView.subviewslet uibutton = buttons[currentPage] as! UIButton

值得一提的是在這邊也要設定underlineView 的 Constraint ,這件事在上述講解『點擊Button產生動畫效果』時已經做過,我們把這段程式碼寫成一個method setButtonConstraint(button: )讓兩邊都可以去呼叫。

func setButtonConstraint(button: UIButton){//先關閉underlineViewWidthConstraint.isActive = falseunderlineViewCenterXConstraint.isActive = falseunderlineViewTopConstraint.isActive = false//改值underlineViewWidthConstraint = underlineView.widthAnchor.constraint(equalTo: button.widthAnchor)underlineViewCenterXConstraint = underlineView.centerXAnchor.constraint(equalTo: button.centerXAnchor)underlineViewTopConstraint = underlineView.topAnchor.constraint(equalTo: button.bottomAnchor)//再ActiveunderlineViewWidthConstraint.isActive = trueunderlineViewCenterXConstraint.isActive = trueunderlineViewTopConstraint.isActive = trueUIViewPropertyAnimator(duration: 0.5, curve: .easeInOut) {self.view.layoutIfNeeded()}.startAnimation()}

在這邊宣告一個參數button,將UIButton傳進來就可以把底線移動到該button下方

func setButtonConstraint(button: UIButton)

其餘程式碼前面已說明過,因此不再贅述。

現在水平滑動頁面已經可以同步移動Button的底線了,那麼我們反向的點擊UIButton也要控制頁面到正確的位置上。

這時候要來改寫在『點擊Button產生動畫效果』提到的func changePage(sender: )

@objc func changePage(sender: UIButton){setButtonConstraint(button: sender)let targetX = CGFloat(sender.tag) * width!scrollView.setContentOffset(CGPoint(x: targetX, y: 0), animated: true)}

可以看到我們利用在override func viewDidLoad()設定的UIbutton屬性tag與override func viewDidLayoutSubviews()設定的width計算出scrollView的ContentOffset X軸位置。
只要呼叫setContentOffset就可以讓scrollView滑到我們所指定的位置(UIButton指定的頁面)。

大功告成

若內容有誤煩請指教,感謝收看。

--

--