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
呢?
可以到 SceneDelegate
的 scene 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
裡展示視圖(例如UILabel
或UIButton
),應該將它們添加到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
講完 TodoTableViewCell
跟 FloatActionButton
後,接下來是 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
:用於決定SubView
或layer
是否會被ParentView
的邊界限制。masksToBounds = true
代表SubView
或layer
將會被限制在該視圖的邊界內,masksToBounds = false
代表SubView
或layer
不會被邊界限制
這邊來介紹一下 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