#36–1 Page View Controller 引導頁/飛入、搖動、仿射轉換動畫/Tab Bar Controller/利用 Responder Chain 實現點選按鈕跳任一 Tab(純程式)

Add child view controller / UIPageViewController / UIViewPropertyAnimator / CAKeyframeAnimation / intrinsicContentSize / UITextFieldDelegate / status bar & navigation bar colors / UITabBarController / Responder Chain

Ethan
38 min readJan 9, 2022
Demo

OnboardingContainerViewController

❖ init 階段

  1. vcs 放 vc1~vc4。
  2. pageVC 呼叫 setViewControllers,設定要呈現的 vc 和方向。
import UIKitclass OnboardingContainerViewController: UIViewController {

var vcs = [UIViewController]()
let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
let initialIndex = 0

// external controls
let skipButton = UIButton()
let nextButton = UIButton()
let pageControl = UIPageControl()

// for animations
var skipButtonTopAnchor: NSLayoutConstraint?
var nextButtonTopAnchor: NSLayoutConstraint?
var pageControlBottomAnchor: NSLayoutConstraint?
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
let vc1 = OnboardingViewController(imageName: "onboard", titleText: "Onboarding screen", subtitleText: "Using UIPageViewController.")
let vc2 = OnboardingViewController(imageName: "login", titleText: "Login screen animation", subtitleText: "UIViewPropertyAnimator; CAKeyframeAnimation.")
let vc3 = OnboardingViewController(imageName: "chain", titleText: "Programmatic TabBarController", subtitleText: "UITabBarController; Responder Chain.")
let vc4 = LoginViewController()
vcs.append(vc1)
vcs.append(vc2)
vcs.append(vc3)
vcs.append(vc4)


pageVC.setViewControllers([vcs[initialIndex]], direction: .forward, animated: true)

super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()
setup()
style()
layout()
}
}

❖ pageVC 設為 OnboardingContainerViewController 的 child view controller 三步驟,再將 pageVC.view 做 Auto Layout 撐滿整個 view。

animateControlsIfNeeded 將最後一頁(登入頁)的按鈕和 page control 改變 constraint 收掉。不是最後一頁則恢復原狀。

extension OnboardingContainerViewController {
func setup() {
addChild(pageVC)
view.addSubview(pageVC.view)
pageVC.didMove(toParent: self)


pageVC.dataSource = self
pageVC.delegate = self

pageControl.addTarget(self, action: #selector(pageControlTapped(_:)), for: .valueChanged)
skipButton.addTarget(self, action: #selector(skipTapped(_:)), for: .primaryActionTriggered)
nextButton.addTarget(self, action: #selector(nextTapped(_:)), for: .primaryActionTriggered)
}

func style() {
view.backgroundColor = .systemBackground

pageControl.currentPageIndicatorTintColor = .systemGreen
pageControl.pageIndicatorTintColor = .systemGray2
pageControl.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
pageControl.numberOfPages = vcs.count
pageControl.currentPage = initialIndex
skipButton.setTitle("Skip", for: .normal)
skipButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .title3)
skipButton.setTitleColor(.label, for: .normal)
nextButton.setTitle("Next", for: .normal)
nextButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .title3)
nextButton.setTitleColor(.label, for: .normal)
}

func layout() {
pageVC.view.translatesAutoresizingMaskIntoConstraints = false
pageControl.translatesAutoresizingMaskIntoConstraints = false
skipButton.translatesAutoresizingMaskIntoConstraints = false
nextButton.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(pageControl)
view.addSubview(skipButton)
view.addSubview(nextButton)

NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: pageVC.view.topAnchor),
view.leadingAnchor.constraint(equalTo: pageVC.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pageVC.view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pageVC.view.bottomAnchor),


pageControl.widthAnchor.constraint(equalTo: view.widthAnchor),
pageControl.heightAnchor.constraint(equalToConstant: 20),
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),

skipButton.leadingAnchor.constraint(equalToSystemSpacingAfter: view.leadingAnchor, multiplier: 2),
view.trailingAnchor.constraint(equalToSystemSpacingAfter: nextButton.trailingAnchor, multiplier: 2),
])
// for animations pageControlBottomAnchor = view.bottomAnchor.constraint(equalToSystemSpacingBelow: pageControl.bottomAnchor, multiplier: 5)
skipButtonTopAnchor = skipButton.topAnchor.constraint(equalToSystemSpacingBelow: view.safeAreaLayoutGuide.topAnchor, multiplier: 2)
nextButtonTopAnchor = nextButton.topAnchor.constraint(equalToSystemSpacingBelow: view.safeAreaLayoutGuide.topAnchor, multiplier: 2)
pageControlBottomAnchor?.isActive = true
skipButtonTopAnchor?.isActive = true
nextButtonTopAnchor?.isActive = true
}

private func animateControlsIfNeeded() {
let isLastPage = pageControl.currentPage == vcs.count - 1
if isLastPage {
// hideControls
pageControlBottomAnchor?.constant = -80
skipButtonTopAnchor?.constant = -80
nextButtonTopAnchor?.constant = -80
} else {
// showControls
pageControlBottomAnchor?.constant = 8*5
skipButtonTopAnchor?.constant = 8*2
nextButtonTopAnchor?.constant = 8*2
}
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0, options: [.curveEaseOut], animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
}

❖ 觸發函式

  1. 目前 vc 的找法是 pageVC.viewControllers?[0]
  2. 下一個 vc 的找法是 pageVC.dataSource?.pageViewController(pageVC, viewControllerAfter: currentVC)
  3. 點 skip 或 next,pageControl.currentPage 要設定目標 index。在最後一頁按 next,pageControl 點點要跳回第一頁的話,可以寫:pageControl.currentPage = (pageControl.currentPage+1) % pageControl.numberOfPages
  4. 最後都要呼叫 animateControlsIfNeeded 判斷是否要執行動畫。
// MARK: - Press Actions
extension OnboardingContainerViewController {
@objc func pageControlTapped(_ sender: UIPageControl) {
pageVC.setViewControllers([vcs[sender.currentPage]], direction: .forward, animated: true, completion: nil)
animateControlsIfNeeded()
}
@objc func skipTapped(_ sender: UIButton) {
let lastPageIndex = vcs.count - 1
pageControl.currentPage = lastPageIndex
pageVC.setViewControllers([vcs[lastPageIndex]], direction: .forward, animated: true, completion: nil)
animateControlsIfNeeded()
}

@objc func nextTapped(_ sender: UIButton) {
pageControl.currentPage = (pageControl.currentPage+1) % pageControl.numberOfPages
guard let currentVC = pageVC.viewControllers?[0] else { return }
guard let nextVC = pageVC.dataSource?.pageViewController(pageVC, viewControllerAfter: currentVC) else { return }
pageVC.setViewControllers([nextVC], direction: .forward, animated: true, completion: nil)
animateControlsIfNeeded()
}
}

❖ UIPageViewControllerDataSource / UIPageViewControllerDelegate

先找到目前 index,就可以設定目前 vc 的前一頁 vc 和後一頁 vc,我們這邊還設定可以循環翻頁

為了讓滑動時也能切換 page control 點點,在下列方法,pageControl.currentPage 要設定目標 index。(因為此方法是滑動後所呼叫,所以 index 已為目前 index。

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool)

最後呼叫 animateControlsIfNeeded。

extension OnboardingContainerViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {

func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let currentIndex = vcs.firstIndex(of: viewController) else { return nil }
return currentIndex == 0 ? vcs.last : vcs[currentIndex - 1]

}

func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let currentIndex = vcs.firstIndex(of: viewController) else { return nil }
return currentIndex < vcs.count - 1 ? vcs[currentIndex + 1] : vcs.first

}

// UIPageViewControllerDelegate
// keep pageControl in sync with viewControllers
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard let viewControllers = pageViewController.viewControllers else { return }
guard let currentIndex = vcs.firstIndex(of: viewControllers[0]) else { return }

pageControl.currentPage = currentIndex
animateControlsIfNeeded()
}
}

OnboardingViewController

vc1 ~ vc3 都是它。init 階段要顯示圖片、標題、副標題。

LoginViewController

vc4 是它。

username 跟 password 分別從客製 EmailView 和 PasswordView 的 text field 取得。(如何客製後述)

viewDidAppear 階段要呼叫「標題副標題飛進來(有時間差)+顯現動畫」。動畫靠改變 leading anchor 的 constant 達成。

import UIKitclass LoginViewController: UIViewController {

let titleLabel = UILabel()
let subtitleLabel = UILabel()
let emailView = EmailView()
let dividerView = UIView()
let passwordView = PasswordView()
let signInButton = UIButton(type: .system)
let errorMessageLabel = UILabel()
var username: String? {
return emailView.usernameTextField.text
}
var password: String? {
return passwordView.passwordTextField.text
}
// animation
var leadingEdgeOnScreen: CGFloat = 16
var leadingEdgeOffScreen: CGFloat = -1000
var titleLeadingAnchor: NSLayoutConstraint?
var subtitleLeadingAnchor: NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
style()
layout()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animate()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
signInButton.configuration?.showsActivityIndicator = false
}
// 標題副標題飛進來+顯現動畫
private func animate() {
let duration = 0.8
let animator1 = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) {
self.titleLeadingAnchor?.constant = self.leadingEdgeOnScreen
self.view.layoutIfNeeded()
}
animator1.startAnimation()
let animator2 = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) {
self.subtitleLeadingAnchor?.constant = self.leadingEdgeOnScreen
self.view.layoutIfNeeded()
}
animator2.startAnimation(afterDelay: 0.2)
let animator3 = UIViewPropertyAnimator(duration: duration*2, curve: .easeInOut) {
self.titleLabel.alpha = 1
self.subtitleLabel.alpha = 1
self.view.layoutIfNeeded()
}
animator3.startAnimation(afterDelay: 0.2)
}
}

❖ style() 和 layout()

style() 內容在 viewDidLoad 階段就會執行,所以要在這裡設定飛進來動畫的初始狀態。

button 的 configuration 設定可以看一下,按鈕內左邊有放一個 indicator ,所以要留一個 imagePadding 避免文字跟圖太近

❖ 登入按鈕觸發

顯現 errorMessageLabel 後要做登入檢查。登入檢查前兩篇文章做很多,這次重點放在 errorMessageLabel 關鍵影格動畫

若 email 或 password 不合,呼叫 setupShakeButton,傳入 message。裡頭要顯示並顯現 errorMessageLabel,然後要在 signInButton 的 layer 加入 CAKeyframeAnimation 的物件 animation。

可以在該 animation 設定 keyPath、values、keyTimes、duration 等關鍵影格動畫所需參數。

❖ EmailView 內容

可以用一個 struct 擺放專給 EmailView 使用的顏色、高度。
intrinsicContentSize 回傳指定的高度,這樣上一層的 autolayout 就不需要設定高度。

・placeholder 是 “Email” 和 “Password” 灰色字樣,成功進入編輯狀態後,會縮小、變綠色、往左上移動。

・emailTextField 是輸入區,游標顏色在輸入內容有效時是綠色,無效時是紅色。

・invalidLabel 是紅色小字,擺放位置與 placeholder 移動後的位置要重疊。

・cancelButton 是灰色的、取消編輯狀態的按鈕。

import UIKitprivate struct Local {
static let height: CGFloat = 60
static let tintColorValid: UIColor = .systemGreen
static let tintColorInValid: UIColor = .systemRed
static let backgroundColor: UIColor = .systemGray5
static let foregroundColor: UIColor = .systemGray
}
class EmailView: UIView {
private enum EditState {
case notEditing
case isEditing
}
private var editState = EditState.notEditing


private let placeholder = UILabel()
let emailTextField = UITextField()
private let invalidLabel = UILabel()
private let cancelButton = makeSymbolButton(systemName: "clear.fill", target: self, selector: #selector(cancelTapped(_:)))

override init(frame: CGRect) {
super.init(frame: frame)
setup()
style()
layout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: Local.height)
}
}

setup / style / layout

▶︎ 點選自己(EmailView)並放開後,若 editState 為 EditState.notEditing 才執行進入編輯區(enterAnimation)的動畫。

裡頭要設定目標樣式、placeholder 移動目標,self.editState = .isEditing。

動畫完成後,記得 emailTextField.becomeFirstResponder。(接著會先執行 textFieldShouldBeginEditing 裡面的東西,textField 才會 become first responder)

【問題】為什麼顯現 cancelButton 和 emailTextField 要寫在 textFieldShouldBeginEditing 內?

【答】因為有種狀況是在 EmailView 和 PasswordView 之間切換,所以一點下去,textField 將 becomeFirstResponder,因此要做 cancelButton 顯現、emailTextField 顯現。

// 若 textField 呼叫 becomeFirstResponder 會先進來
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
cancelButton.isHidden = false
emailTextField.isHidden = false
return true // textField 會 become first responder
}

▶︎ 點選 cancelButton,動畫內記得先 emailTextField.resignFirstResponder()。(接著會先執行 textFieldShouldEndEditing 裡面的東西,textField 才會 resign first responder)

textFieldShouldEndEditing 內要檢查合格字串,因此又用到:

檢查完字串要改變 UI 樣式,無論合格或不合格都要隱藏 cancelButton。

// 若 textField 呼叫 resignFirstResponder 會先進來
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
if let text = textField.text, isValidEmail(text) {
// 合格字串
placeholder.isHidden = false //綠色字樣
invalidLabel.isHidden = true //隱藏紅色字樣
layer.borderColor = Local.tintColorValid.cgColor //綠框框
textField.tintColor = Local.tintColorValid //游標、反白變綠色
} else {
// 不合格字串
placeholder.isHidden = true //隱藏綠色字樣
invalidLabel.isHidden = false //紅色字樣
layer.borderColor = Local.tintColorInValid.cgColor //紅框框
textField.tintColor = Local.tintColorInValid //游標、反白變紅色
}

cancelButton.isHidden = true //隱藏cancelButton
return true // textField 會 resign first responder
}

再來,resign first responder 後,動畫內繼續設定目標樣式、顯現隱藏、editState = .notEditing、placeholder 移動到原位置。

【問題】為什麼隱藏 cancelButton 要寫在 textFieldShouldEndEditing 內?

【答】因為我寫了

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}

所以按了小鍵盤 return 後,textField 將 resignFirstResponder,因此要做檢查字串跟隱藏 cancelButton。

另外一種狀況就是在 EmailView 和 PasswordView 之間切換,這時點離,textField 也將 resignFirstResponder,因此一樣要做檢查字串跟隱藏 cancelButton。

❖ PasswordView 內容

原理同上,所以只貼有效密碼判斷輔助函式:

func isValidPassword(_ password: String) -> Bool {
return password.count >= 6 && containsDigit(password) && containsLowerCase(password) && containsUpperCase(password)
}
private func containsDigit(_ value: String) -> Bool {
let reqularExpression = ".*[0-9]+.*"
let predicate = NSPredicate(format: "SELF MATCHES %@", reqularExpression)
return predicate.evaluate(with: value)
}
private func containsLowerCase(_ value: String) -> Bool {
let reqularExpression = ".*[a-z]+.*"
let predicate = NSPredicate(format: "SELF MATCHES %@", reqularExpression)
return predicate.evaluate(with: value)
}
private func containsUpperCase(_ value: String) -> Bool {
let reqularExpression = ".*[A-Z]+.*"
let predicate = NSPredicate(format: "SELF MATCHES %@", reqularExpression)
return predicate.evaluate(with: value)
}

AppDelegate

上篇文已經講過如何處理開啟 App 後的跳轉,以及利用 Notification 觀察者處理登入登出通知。

這次多處理了 hasOnboarded 狀態(第一次登入後即改成 true)跟在登入後設定 status bar 和 navigation bar 顏色

private func prepMainView() {
// status bar
mainVC.setStatusBar()
// nav bar
UINavigationBar.appearance().backgroundColor = .systemBackground
}

其中 setStatusBar 是 UIViewController 的 Extension,可設定 status bar 顏色等。

func setStatusBar() {
let statusBarSize = UIApplication.shared.statusBarFrame.size // deprecated but OK
let frame = CGRect(origin: .zero, size: statusBarSize)
let statusbarView = UIView(frame: frame)
statusbarView.backgroundColor = .systemBackground //有用
view.addSubview(statusbarView)
}

MainViewController

把五個 controllers(有 view controller 也有 navigation controller)裝進 mainTabBarController,設定 Tab Bar 顏色並讓 mainTabBarController 成為 MainViewController 的 child view controller。此時這五個 controllers 變成 MainViewController 的 children。

import UIKitclass MainViewController: UIViewController {
let mainTabBarController = UITabBarController()

override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nil, bundle: nil)
setup()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension MainViewController {
func setup() {
let homeVC = HomeViewController()
let scanNC = makeNavigationController(rootViewController: ScanViewController())
let orderNC = makeNavigationController(rootViewController: OrderViewController())
let giftNC = makeNavigationController(rootViewController: GiftViewController())
let storeNC = makeNavigationController(rootViewController: StoreViewController())

mainTabBarController.viewControllers = [homeVC, scanNC, orderNC, giftNC, storeNC]
mainTabBarController.tabBar.tintColor = .systemGreen

// add mainTabBarController as child controller
addChild(mainTabBarController)
view.addSubview(mainTabBarController.view)
didMove(toParent: self)

}

// 後面四個 NC 用的
func makeNavigationController(rootViewController: UIViewController) -> UINavigationController {
let navC = UINavigationController(rootViewController: rootViewController)

// 大標題
navC.navigationBar.prefersLargeTitles = true
let attrs = [
NSAttributedString.Key.foregroundColor: UIColor.label,
NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .title1).bold()
]
navC.navigationBar.largeTitleTextAttributes = attrs

return navC
}
}

小補充:新增或移除 child view controller 也可以運用下面的 UIViewController extension:

接著用一個 MyResponder protocol 去捕捉所有 responder chain 事件,像是一個 didTapScan 的觸發按鈕函式。

@objc protocol MyResponder {
@objc optional func didTapScan(sender: UIButton?)
}

此後遵從 MyResponder 的 MainViewController 就能定義 didTapScan 函式。我們讓 mainTabBarController 的 selectedIndex 改成指定傳入的 index,即可實現點選按鈕後的 Tab 間跳躍。

extension MainViewController: MyResponder {

func didTapScan(sender: UIButton?) {
presentTabBar(withIndex: 1)
}

private func presentTabBar(withIndex index: Int) {
mainTabBarController.selectedIndex = index
}
}

HomeViewController

我們讓五個 root view controller 都繼承一個 BaseViewController,把 initializers 寫在裡面。然後創一個沒內容的 commonInit() 函式,繼承者就可以去覆寫它。

import UIKitclass BaseViewController: UIViewController {

override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nil, bundle: nil)
commonInit()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func commonInit() {}
}

HomeViewController 為例(其他四個 view controller 就不提了)override func commonInit()內可呼叫下面 UIViewController Extension 函式去製作 Tab。(帶圖片的按鈕也是用 SymbolConfiguration 做的)

func setTabBarImage(imageName: String, title: String) {
let image = UIImage(systemName: imageName, withConfiguration: UIImage.SymbolConfiguration(scale: .large))
tabBarItem = UITabBarItem(title: title, image: image, tag: 0)
}

scanButton 的 target action 可用 Selector 的 Extension 集中管理比較好:

public extension Selector {
static let didTapScan = #selector(MyResponder.didTapScan(sender:))
}
還要小心一件事:
title 改變,Tab bar 的 title 也會連帶受影響。

❖ HomeHeaderView

intrinsicContentSize 有高度了,所以在 HomeViewController 不用寫它的高度 constraint。

有一個大標題標籤跟三個按鈕,帶圖片按鈕製作可以看我的 iOS 工具箱

點第三個按鈕會發送登出 Notification。

之後的文章再實現捲動下拉後讓 HomeHeaderView 卡住一部份的功能。

--

--

Ethan

Life is what happens to you while you’re busy making other plans.