iOS 開發 #14 用全編程設計的 UIKit 元件來做 App ( 1 )

沒錯,熟悉的 Todo List App 又來了

--

前言

上次介紹了 SwiftUI 也用 SwiftUI 的方式來完成 Todo List App,雖說喜歡 SwiftUI 的開發方式但轉頭看了公司的專案大多都是用 OC ( Objective C ) + UIKit 的開發方式跟 Swift + UIKit 的開發方式,想想還是得好好學習一下 UIKit 的使用方法

Todo List App UI 設計

這次用的 UI 跟 iOS 開發 #12 | SwiftUI 這篇的 UI 一樣就不多說了

修改 SceneDelegate

在這次打算不使用 Storyboard 的方式且不使用預設的 ViewController ,那要怎麼顯示自定義的 ViewController 呢?

可以到 SceneDelegatescene function 裡面做設定

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

guard let _ = (scene as? UIWindowScene) else { return }

window = UIWindow(windowScene: scene as! UIWindowScene)

// 這邊輸入你要設為 rootViewController
// 這邊用 HomeViewController
let HomeViewController = HomeViewController()
window?.rootViewController = HomeViewController
window?.makeKeyAndVisible()
}

這樣當你用模擬器去執行時就會顯示你設定的 ViewController

Preview

這邊先講講如果要用編程的方式來設計 UI 的話,這樣就不能用 Storyboard 的方式來看 UI 是否是我們要的樣子,這時候就可以用 #Preview 的方式來檢視,使用方法如下

  • viewcontroller
#Preview {
UINavigationController(rootViewController: 這邊放你要顯示的 ViewController)
}
  • viewcontroller
#Preview {
這邊放你要顯示的東西
}

TodoTableViewCell

畢竟這次是用 UIKit 中的 UITableView 來設計 UI,所以我把 TodoCard 改叫 TodoTableViewCell

在這個 Cell 中有個 CheckBox 來供使用者勾選 Task 是否完成,那一樣 CheckBox 也是要自己刻出來,所以先上 CheckBox 的 code

CheckBox

import UIKit

class CheckBox {
var isChecked: Bool = false {
didSet {
updateImage()
}
}

private let checkBoxButton: UIButton

init() {
checkBoxButton = UIButton()
checkBoxButton.setImage(UIImage(systemName: "square"), for: .normal)
checkBoxButton.tintColor = .black
checkBoxButton.translatesAutoresizingMaskIntoConstraints = false
updateImage()
}

private func updateImage() {
let imageName = isChecked ? "checkmark.square.fill" : "square"
checkBoxButton.setImage(UIImage(systemName: imageName), for: .normal)
}

var checkBox: UIButton {
return checkBoxButton
}
}


#Preview {
CheckBox().checkBox
}

TodoTableViewCell

import UIKit

class TodoTableViewCell: UITableViewCell {
var title: UILabel!
var checkBox = CheckBox()

override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.isUserInteractionEnabled = true
contentView.backgroundColor = UIColor(cgColor: CGColor(red: 171/255, green: 171/255, blue: 171/255, alpha: 1))

title = UILabel()
title.text = "Todo"
title.translatesAutoresizingMaskIntoConstraints = false

checkBox.checkBox.addTarget(self, action: #selector(checkBoxTapped), for: .touchUpInside)
checkBox.checkBox.translatesAutoresizingMaskIntoConstraints = false
checkBox.checkBox.widthAnchor.constraint(equalToConstant: 30).isActive = true
checkBox.checkBox.heightAnchor.constraint(equalToConstant: 30).isActive = true

let mainStackView = UIStackView(arrangedSubviews: [
checkBox.checkBox,
title,
])
mainStackView.axis = .horizontal
mainStackView.spacing = 16
mainStackView.alignment = .center
mainStackView.distribution = .fill
mainStackView.translatesAutoresizingMaskIntoConstraints = false

contentView.addSubview(mainStackView)

NSLayoutConstraint.activate([
// MARK: auto layout - mainStackView
mainStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
mainStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
mainStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
mainStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
])
}

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

@objc func checkBoxTapped() {
checkBox.isChecked.toggle()
}
}


#Preview {
TodoTableViewCell()
}

這邊來介紹上面的一些參數

  • translatesAutoresizingMaskIntoConstraints :用來控制自動調整遮罩的轉換
  • addTarget :用來新增 button 點擊事件
  • NSLayoutConstraint.activate :用來啟用一組 Auto Layout 約束的函式
  • isUserInteractionEnabled :決定 ViewController 是否可以響應用戶的交互事件
  • arrangedSubviews :用於在 UIStackView 中用來管理 subviews 的佈局管理,arrangedSubviews 會自動參與到 UIStackView 的佈局邏輯中,並自動遵循 stack view 的規則
  • contentView.addSubview :用於把物件加入到 Cell
  • widthAnchor.constraint(equalToConstant: ) / heightAnchor.constraint(equalToConstant: ) :用於定義物件大小
  • isActive :用來啟用或停用 Auto Layout 的約束。isActive = true 代表這個約束會被啟用並影響界面的佈局,isActive = false 代表這個約束會被停用,Auto Layout 系統不會應用這個約束

這邊用 ChatGPT 來解釋一下為什麼要用 contentView.addSubView 而不是用 addSubView

為什麼使用 contentView.addSubview() 而不是直接 addSubview()

UITableViewCell 裡,每個 cell 有一個主要的容器視圖,叫做 contentView,它是應該添加子視圖的地方。當想在 cell 裡展示視圖(例如 UILabelUIButton),應該將它們添加到 contentView,而不是直接對 UITableViewCell 添加子視圖

原因是 UITableViewCell 的結構設計專門為了讓 contentView 處理和管理所有的子視圖,這樣做也有助於在螢幕上繪製和佈局時提高性能

直接對 cell 本身使用 addSubview 是不建議的,因為這樣可能會導致子視圖的佈局出現問題,特別是在進行自動佈局或滾動時

FloatActionButton

接著是 FloatActionButton

import UIKit

class FloatActionButton {
var floatingButton: UIButton {
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 60, height: 60))
button.layer.masksToBounds = true
button.layer.cornerRadius = 20
button.tintColor = .black
button.setImage(UIImage(systemName: "plus"), for: .normal)
button.backgroundColor = UIColor(cgColor: CGColor(red: 231/255, green: 224/255, blue: 236/255, alpha: 1))
button.setBackgroundColor(UIColor(cgColor: CGColor(red: 200/255, green: 200/255, blue: 200/255, alpha: 1)), for: .highlighted)

button.layer.shadowColor = UIColor.black.cgColor
button.layer.shadowOpacity = 0.3
button.layer.shadowOffset = CGSize(width: 0, height: 5)
button.layer.shadowRadius = 10

return button
}
}

extension UIButton {
func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
let minimumSize: CGSize = CGSize(width: 1.0, height: 1.0)
UIGraphicsBeginImageContext(minimumSize)

if let context = UIGraphicsGetCurrentContext() {
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: .zero, size: minimumSize))
}

let colorImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

self.setBackgroundImage(colorImage, for: state)
}
}

這段 FloatActionButton 是參考下面這個影片的

extension 是要設定 FAB (FloatActionButton) 點擊與預設的顏色,透過 extension 來拓展 UIButton 的功能

若對於 extension 感興趣的可以看看這篇文章

AddTaskViewController

講完 TodoTableViewCellFloatActionButton 後,接下來是 AddTaskPage ,因為是使用 ViewController ,所以我把 AddTaskPage 改叫 AddTaskViewController

import UIKit

class AddTaskViewController: UIViewController {

var addTask: ((String) -> Void)?
let textField = PaddingTextField()

override func viewDidLoad() {
super.viewDidLoad()

setupView()
}

private func setupView() {
view.backgroundColor = .white

let titleLabel = UILabel()
titleLabel.text = "Add Task"
titleLabel.font = .systemFont(ofSize: 30, weight: .bold)
titleLabel.translatesAutoresizingMaskIntoConstraints = false

textField.placeholder = "Enter task name"
textField.translatesAutoresizingMaskIntoConstraints = false
textField.layer.cornerRadius = 14
textField.layer.borderWidth = 1

let addButton = UIButton()
addButton.translatesAutoresizingMaskIntoConstraints = false

var config = UIButton.Configuration.filled()
config.title = "Add"
config.baseForegroundColor = .black
config.baseBackgroundColor = UIColor(cgColor: CGColor(red: 171/255, green: 171/255, blue: 171/255, alpha: 1))
config.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)

addButton.configuration = config
addButton.layer.cornerRadius = 14
addButton.layer.masksToBounds = true
addButton.addTarget(self, action: #selector(onClick), for: .touchUpInside)

let stackView = UIStackView(arrangedSubviews: [titleLabel, textField, addButton])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fill
stackView.alignment = .fill
stackView.spacing = 16

view.addSubview(stackView)

NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),

])
}

@objc private func onClick() {
guard let taskName = textField.text, !taskName.isEmpty else {
print("Please enter a task name.")
return
}

addTask?(taskName)
dismiss(animated: true, completion: nil)
}
}

class PaddingTextField: UITextField {
var textPadding = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)

// 控制 placeholder 和非編輯狀態時的內邊距
override func textRect(forBounds bounds: CGRect) -> CGRect {
return bounds.inset(by: textPadding)
}

// 控制正在編輯狀態時的內邊距
override func editingRect(forBounds bounds: CGRect) -> CGRect {
return bounds.inset(by: textPadding)
}

// 控制 placeholder 的內邊距
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
return bounds.inset(by: textPadding)
}
}

#Preview {
UINavigationController(rootViewController: AddTaskViewController())
}

這邊來介紹上面的一些參數

  • UIButton.Configuration :用於設定 UIButton 的外觀設定
  • masksToBounds :用於決定 SubViewlayer是否會被 ParentView 的邊界限制。masksToBounds = true 代表 SubViewlayer 將會被限制在該視圖的邊界內, masksToBounds = false 代表 SubViewlayer 不會被邊界限制

這邊來介紹一下 PaddingTextField 在這邊的用意

UIButton 中有 UIButton.Configuration 來設定 UIButton 的外觀,而在 UITextField 只能用一個 CustomTextField 繼承 UITextField 後來設定外觀,而 PaddingTextField 主要用處是設定 hint (placeholder) 的間隔

  • textRect :控制 placeholder 和非編輯狀態時的內邊距
  • editingRect :控制正在編輯狀態時的內邊距
  • placeholderRect :控制 placeholder 的內邊距

在設定完 PaddingTextField 後就可以讓其他 TextField 去繼承,這樣顯示出來就能讓 placeholder 有間隔

HomeViewController

最後是 HomeViewController

import UIKit

class HomeViewController: UIViewController {
var fab = FloatActionButton().floatingButton

override func viewDidLoad() {
super.viewDidLoad()

setupView()
view.addSubview(fab)
}

func setupView() {
view.backgroundColor = .white
title = "Todo List"

let todoListTableVidew = UITableView()
todoListTableVidew.translatesAutoresizingMaskIntoConstraints = false
todoListTableVidew.register(TodoTableViewCell.self, forCellReuseIdentifier: "\(TodoTableViewCell.self)")
todoListTableVidew.delegate = self
todoListTableVidew.dataSource = self
todoListTableVidew.allowsSelection = false

view.addSubview(todoListTableVidew)

NSLayoutConstraint.activate([
todoListTableVidew.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
todoListTableVidew.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
todoListTableVidew.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
todoListTableVidew.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])
}

/*
// MARK: - Navigation

// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
}
*/

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

fab.frame = CGRect(
x: Int(view.frame.size.width) - 60 - 30,
y: Int(view.frame.size.height) - 60 - 50,
width: 60,
height: 60
)
fab.addTarget(self, action: #selector(fabOnClick), for: .touchUpInside)
}

@objc func fabOnClick() {
let sheetVC = AddTaskViewController()

sheetVC.addTask = { [weak self] task in
print(task)
}

if let sheet = sheetVC.sheetPresentationController {
sheet.prefersGrabberVisible = true
sheet.detents = [ .custom(resolver: { content in
300
}) ]
}
self.present(sheetVC, animated: true, completion: nil)
}
}

// MARK: Data Source
extension HomeViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
10
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(TodoTableViewCell.self)", for: indexPath) as? TodoTableViewCell else {return UITableViewCell()}
cell.title.text = "Todo \(indexPath.row)"

return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
}

// MARK: Delegate
extension HomeViewController: UITableViewDelegate {
// todo
}

#Preview {
UINavigationController(rootViewController: HomeViewController())
}

這邊來介紹上面的一些參數

  • register(你要顯示的 Cell , forCellReuseIdentifier: 該 Cell 的 Identifier ) :註冊 Cell ,用於指定顯示的 class ( Cell )

目前這個只有先完成 UI 的部分,下面是這篇的 git 連結

後記

這篇主要是用來介紹如何用 Code 的方式來刻畫與顯示 UIKit

--

--