【 iOS Swift 】#17 App的教學導覽頁 Onboarding View (純Code)

在安裝新App 時,是不是時常會看見跳出的功能導覽圖,教你如何使用 App,或是告訴你這次新功能有哪些。

成品如下:

彼得這兒有清晰的 StoryBoard 教學,可以先把 StoryBoard 熟練,

再跟著我一起往下做純 Code~

如果想要抓現成的圖來練習,可以到Figma搜尋「Onboarding View 相關」

把人家的設計稿Duplicate一份後,可以先把一些元件拿掉,只取圖檔的部分,其他元件自己用Swift加。

我選擇使用的是:

https://www.figma.com/file/MiBfoj9L0Gdrmz9sCJGwXL/Onboardings---Pet-Care-App-(Community)?type=design&node-id=0-1&mode=design&t=wuwggFzR8j1rFxTY-0

假如我希望使用者在進入首頁前,可以先逼他看完教學導覽圖,我就可以寫在我的 HomePageViewController的viewDidAppear:

import UIKit

class HomePageViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()
}

// 在首頁的VC顯示完馬上顯示教學導覽
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)

// 希望他只出現一次,所以用UserDefault儲存使用者看過了沒
let userDefault = UserDefaults.standard

if (!userDefault.bool(forKey: "hasSeenTutorial")) {

// 把教學導覽的VC生出來,並全螢幕Present出來
let tutorialVC = TutorialViewController()
tutorialVC.setUserDefaultKey("hasSeenTutorial")
tutorialVC.modalPresentationStyle = .fullScreen

self.present(tutorialVC, animated: true)
}
}
}

其中,TutorialViewController的主要概念是透過ScrollView + StackView + UIView的方式來達成,如上圖所示。

接著,跟著我一步一步打造TutorialViewController:

  1. 建立元件
  2. 加入 scroll view,佔滿整個螢幕
  3. 加入 stack view,並讓其與 scroll view 的上下左右間距為 0
  4. 加入Page Control、Button等元件,設定每一頁按鈕按下的 Action
  5. 製作每一個分頁的UI,讓每一個分頁的UI的寬高等於螢幕的寬高
  6. 設定當手勢滑動時,更新目前分頁 Index
  7. 強制讓使用者當下的上方時間、電池訊號列變成白色

一、建立元件

import UIKit

class TutorialViewController: UIViewController, UIScrollViewDelegate {

let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.isPagingEnabled = true
return scrollView
}()

let stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 0
stackView.distribution = .fillEqually
return stackView
}()

let controlStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 0
stackView.distribution = .equalSpacing
return stackView
}()

let pageControl: UIPageControl = {
let pageControl = UIPageControl()
pageControl.isUserInteractionEnabled = false
pageControl.pageIndicatorTintColor = .darkGray
pageControl.currentPageIndicatorTintColor = .white
pageControl.allowsContinuousInteraction = false
return pageControl
}()

// 右側往下一頁的按鈕
let circleButton: UIButton = {
let button = UIButton()
if let image = UIImage(systemName: "arrow.right") {
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
button.tintColor = .black
}
button.backgroundColor = .white
return button
}()

// 首、尾頁的按鈕
let pillButton: UIButton = {
let button = UIButton()
button.setTitle("Let's Discover", for: .normal)
button.backgroundColor = .white
button.setTitleColor(.black, for: .normal)
button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
return button
}()

...

二、加入 scroll view,佔滿整個螢幕

func setupUI() {

view.addSubview(scrollView)
scrollView.delegate = self
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])

...

三、加入 stack view,並讓其與 scroll view 的上下左右間距為 0

(Content Layout Guide:ScrollView決定捲動的範圍)

scrollView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor)
])

四、加入Page Control、Button等元件,設定每一頁按鈕按下的 Action

// ControlStackView = PageControl + CircleButton
view.addSubview(controlStackView)
controlStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controlStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
controlStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 36),
controlStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -36),
controlStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -72),
])

// PageControl
// 記得加東西到stackView上都是用addArrangedSubview不是addSubview了
controlStackView.addArrangedSubview(pageControl)
pageControl.translatesAutoresizingMaskIntoConstraints = false
pageControl.numberOfPages = tutorialImages.count
pageControl.currentPage = currentIndex

// CircleButton
controlStackView.addArrangedSubview(circleButton)
NSLayoutConstraint.activate([
circleButton.widthAnchor.constraint(equalToConstant: 54),
circleButton.heightAnchor.constraint(equalToConstant: 54)
])

circleButton.layer.cornerRadius = 27
circleButton.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)

controlStackView.isHidden = true

// PillButton
view.addSubview(pillButton)
pillButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pillButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pillButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 36),
pillButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -36),
pillButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -72),
pillButton.heightAnchor.constraint(equalToConstant: 54),
])

pillButton.layer.cornerRadius = 27
pillButton.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)

按下按鈕時的Action:

@objc func buttonPressed() {
// 如果不最後一頁,就Index + 1
if currentIndex < pageViews.count-1 {
currentIndex += 1
scrollView.setContentOffset(pageViews[currentIndex].frame.origin, animated: true)

} else if currentIndex == pageViews.count-1 {
// 最後一頁,要關閉這個VC
self.dismiss(animated: true, completion: nil)
UserDefaults.standard.set(true, forKey: userDefaultKey)
}

pageControl.currentPage = currentIndex
}

透過Property Observer,來讓特地某幾頁的按鈕隱藏與顯示:

var currentIndex: Int = 0 {
didSet {
// 首、尾頁按鈕文字不同
if currentIndex == 0 {
pillButton.isHidden = false
controlStackView.isHidden = true
pillButton.setTitle("Let's Discover", for: .normal)
}
// 除了首、尾頁需要PillButton,其餘則是PageControl配CircleButton
else if currentIndex > 0 && currentIndex < tutorialImages.count-1 {
pillButton.isHidden = true
controlStackView.isHidden = false
}
else {
pillButton.isHidden = false
controlStackView.isHidden = true
pillButton.setTitle("Get Started", for: .normal)
}
print("目前第\(currentIndex)頁")
}
}

五、製作每一個分頁的UI,讓每一個分頁的UI的寬高等於螢幕的寬高

把圖檔放到專案左側欄的Image.asset,接著定義每張圖檔的Array

let tutorialImages = ["Onboarding_1", "Onboarding_2", "Onboarding_3"]

用for重複建構每一頁的擺設,每一頁都是一個UIView,UIView上面放著滿版Image。組裝好後把每一頁一一加到stackView上,並讓每一頁的寬、高將貼齊ScrollView的frameLayoutGuide。

(Frame Layout Guide:ScrollView顯示的範圍)


func addPageView() {

for image in tutorialImages {

let pageView = UIView()

let imageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: image))
imageView.contentMode = .scaleAspectFill
return imageView
}()

pageView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: pageView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: pageView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: pageView.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: pageView.bottomAnchor)
])

stackView.addArrangedSubview(pageView)
pageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
pageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor)
])

pageViews.append(pageView)
}
}

六、設定當手勢滑動時,更新目前分頁 Index

同時也務必讓此VC的class繼承UIScrollViewDelegate,以及scrollView.delegate = self。

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let pageWidth = scrollView.frame.size.width
let currentPage = Int(scrollView.contentOffset.x / pageWidth)
currentIndex = currentPage
print(currentIndex)
}

七、強制讓使用者當下的上方時間、電池訊號列變成白色

// 由於背景圖是深色,把最上方的Status Bar強制調為白色
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}

另外,下方這個function為當別人要用此VC時,可以更改儲存的UserDefault Key。用意是,當最後一頁按鈕點下後,才可以去把指定的userDefault設定為false,讓使用者在下次打開APP時不會再被騷擾。

@objc func setUserDefaultKey(_ key: String) {
userDefaultKey = key
}

此外,你可能注意到,為什麼PageControl的圓點為什麼就是一直無法乖乖貼齊邊界呢?卻有怎麼多padding跑出來

你可以這樣用,但此方法僅在iOS13以上可用:

let pageControl: UIPageControl = {
let pageControl = UIPageControl()
pageControl.isUserInteractionEnabled = false
pageControl.pageIndicatorTintColor = .darkGray
pageControl.currentPageIndicatorTintColor = .white

// 讓pageControl的小圓點貼齊邊界,不留空隙
pageControl.backgroundStyle = .minimal

pageControl.allowsContinuousInteraction = false
return pageControl
}()

大功告成!

完整 Swift Code

--

--

Yen Lin
彼得潘的 Swift iOS / Flutter App 開發教室

iOS Developer in Taiwan | A person who loves to learn cool stuffs. Check out my website to discover more about me: https://yenlin.webflow.io/