Bắt đầu phát triển iOS Apps với Swift part 4: Tự tạo một Custom Control

Chào các bạn, tới thời điểm hiện tại chúng ta đã cùng nhau đi qua 3 phần của series hướng dẫn phát triển app iOS với Swift. part 1: Xây dựng Basic UI part 2: Kết nối UI và Source Code part 3: Làm việc với View Controller

Trong phần tiếp theo này chúng ta sẽ tạo một Custom Control cho phép người dùng đánh giá món ăn. Các kiến thức sẽ tích luỹ được trong bài này bao gồm:

  1. Tạo custom source code file và kết nối nó với các thành phần trong storyboard
  2. Định nghĩa một custom class
  3. Khởi tạo từ custome class
  4. Sử dụng UIStackView như một container
  5. Hiểu được cách tạo views
  6. Làm việc với @IBInspectable@IBDesignable để hiển thị và control custom view ở Interface Builder

I. Tạo Custom View

Ý tưởng của chúng ta khá đơn giản thôi, đó là tạo một stack view subclass để điều khiển một dãy các buttons có biểu tượng ngôi sao. Người dùng tap vào sao để xác định rank của món ăn, tap ngôi sao đó một lần nữa để bỏ đánh giá.

Cách tạo custom stack view (UIStackView) subclass

  1. Chọn File > New > File hoặc Command-N
  2. Chọn iOS
  3. Chọn CCocoa Touch Class,
  4. Ở Class field, gõ RatingControl
  5. Ở Subclass of” field, chọn UIStackView
  6. Ngôn ngữ để Swift
  7. Để các giá trị còn lại default và click Create
  8. Trong file RatingControl.swift mới được tạo ra, xoá hết nội dung và bắt đầu với khai báo class
import UIKit
class RatingControl: UIStackView {
    
}

Có 2 cách thông thường để tạo view: 1 là khai báo bằng source code bằng cách dùng hàm init(frame:), 2 là load từ storyboard với hàm init?(coder:).

Để override hàm khai báo

  1. Trong RatingControl.swift, thêm comment sau ngay dưới dòng định nghĩa class
//MARK: Initialization
  1. init để xuất hiện gợi ý
  2. Chọn init(frame: CGRect)
  3. Click vào cảnh báo xuất hiện để hiển thị chi tiết và click tiếp vào Fix-it để sửa lỗi này.
  4. Thêm dòng dưới đây để gọi khởi tạo của class cha
super.init(frame: frame)
  1. Ngay dưới init(frame:), gõ thêm 1 lần init nữa để gọi init(coder: NSCoder) ra.
  2. Chúng ta sẽ có khởi tạo như sau
override init(frame: CGRect) {
    super.init(frame: frame)
}
 
required init(coder: NSCoder) {
    super.init(coder: coder)
}

II. Hiển thị Custom View

Để hiển thị Custom View, trước hết chúng ta phải thêm stack view vào storyboard và kết nối stack view và code chúng ta vừa viết.

  1. Mở storyboard
  2. Mở Object library và tìm Horizontal Stack View, kéo thả vào màn hình như dưới đây
  3. Mở Identity inspector, chọn RatingControl cho mục Class

III. Thêm buttons vào View

Bắt đầu từ bước đơn giản nhất, ta chỉ thêm 1 button đỏ vào view thôi

  1. Trong RatingControl.swift, thêm chú thích
//MARK: Private Methods
  1. Khai báo method
private func setupButtons() {
    
}
  1. Tạo một button màu đỏ
// Create the button
let button = UIButton()
button.backgroundColor = UIColor.red

Vì chúng ta đang sử dụng Auto Layout, nên bước tiếp theo sẽ là thêm constraints: 4. Thêm constraints cho buttons

// Add constraints
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
button.widthAnchor.constraint(equalToConstant: 44.0).isActive = true

Dòng code đầu tiên disable việc tự động tạo constraints của iOS. Vì chúng ta muốn tự xử lý nó. 5. Cuối cùng chúng ta thêm button vào stack:

// Add the button to the stack
addArrangedSubview(button)

Method này sẽ thêm button vào list views được quản lý bởi RatingControl stack; đồng thời cũng yêu cầu RatingControl tạo các constraints để quản lý vị trí của button. Và hàm setupButtons của chúng ta sẽ như dưới đây

private func setupButtons() {
    
    // Create the button
    let button = UIButton()
    button.backgroundColor = UIColor.red
    
    // Add constraints
    button.translatesAutoresizingMaskIntoConstraints = false
    button.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
    button.widthAnchor.constraint(equalToConstant: 44.0).isActive = true
    
    // Add the button to the stack
    addArrangedSubview(button)
}

setupButtons sẽ được gọi từ hàm khởi tạo.

override init(frame: CGRect) {
    super.init(frame: frame)
    setupButtons()
}
 
required init(coder: NSCoder) {
    super.init(coder: coder)
    setupButtons()
}

Checkpoint: Run app và bạn sẽ thấy button đỏ hiện lên

Tiếp theo là thêm action cho button. Chúng ta sử dụng button này để thay đổi rating của món ăn. Tuy nhiên đầu tiên phải chắc chắn được rằng action cho button này hoạt động đã.

Để thêm action cho button

  1. Ngay sau vùng //MARK Initialization thêm vùng Action
//MARK: Button Action
  1. Thêm action xử lý khi tap và button
func ratingButtonTapped(button: UIButton) {
    print("Button pressed 👍")
}
  1. Vào hàm setupButtons() và thêm setup button action trước khi // Add the button to the stack
// Setup the button action
button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)

Hàm addTarget chúng ta đã ngâm cứu ở các phần trước rồi. Ở đây chỉ cần chú ý tới touchUpInside event. Event này xảy ra khi user ấn vào button và rời tay khỏi màn hình khi vẫn đang trong vùng ảnh hưởng của button đó. Nghĩa là nếu chúng ta chạm và button và di tay ra khỏi button đó và rời tay khỏi màn hình cảm ứng thì event này sẽ ko được ghi nhận. Vì chúng ta không sử dụng Interface Builder nên ko cần phải khai báo IBAction. Chúng ta đơn giản là đang khai báo action như mọi method bình thường khác. Đến đây hàm setupButtons() của chúng ta sẽ có dạng:

private func setupButtons() {
    
    // Create the button
    let button = UIButton()
    button.backgroundColor = UIColor.red
    
    // Add constraints
    button.translatesAutoresizingMaskIntoConstraints = false
    button.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
    button.widthAnchor.constraint(equalToConstant: 44.0).isActive = true
    
    // Setup the button action
    button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
    
    // Add the button to the stack
    addArrangedSubview(button)
}

Khi run app và click vào button thì trên Console sẽ xuất hiện text "Button pressed 👍"

Để thêm giá trị cho người dùng đánh giá.

  1. Ngay dưới phần định nghĩa class của RatingControl.swift. Thêm mảng các buttons.
//MARK: Properties
private var ratingButtons = [UIButton]()
 
var rating = 0

Giá trị mặc định rating đánh giá là 0

Dùng vòng for tạo 5 buttons.

private func setupButtons() {
    
    for _ in 0..<5 {
        // Create the button
        let button = UIButton()
        button.backgroundColor = UIColor.red
        
        // Add constraints
        button.translatesAutoresizingMaskIntoConstraints = false
        button.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
        button.widthAnchor.constraint(equalToConstant: 44.0).isActive = true
        
        // Setup the button action
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
        
        // Add the button to the stack
        addArrangedSubview(button)
        
        // Add the new button to the rating button array
        ratingButtons.append(button)
    }
}

Mở Main.storyboard, chọn RatingControl stack view, mở Attributes inspector và set khoảng cách cho các buttons. Checkpoint: Chúng ta đã có 5 buttons.

IV. Thêm support cho Interface Builder

Dù kết quả khi hiển thị trên màn hình cho ra 4 button nhưng khi mở Main.storyboard ta có thể thấy rằng stack view của chúng ta chỉ là 1 hình chữ nhật lớn duy nhất. Ngoài ra cũng có warning và báo lỗi như dưới đây. Lý do bởi Interface Builder không hề biết gì tới sự tồn tại của RatingControl mà chúng ta vừa viết. Để sửa lỗi đó, chúng ta định nghĩa control này như là một @IBDesignable. Nó cho phép Interface Builder nhận diện và vẽ ra một bản copy của control ngay trên canvas. Khai báo control như một @IBDesignable

  1. Chạy tới RatingControl.swift, phần định nghĩa class
class RatingControl: UIStackView {
  1. Sửa thành
@IBDesignable class RatingControl: UIStackView {
  1. Build lại project
  2. Mở Main.storyboard để check lại kết quả

Interface Builder còn làm được nhiều hơn là chỉ hiển thị. Nó cho phép bạn set thêm các properties ở Attributes inspector. Khi ta thêm @IBInspectable IB sẽ giúp chúng ta hiển thị chúng ở Attributes inspector.

Các thêm inspectable properties (các đặc tính có thể kiểm tra được)

  1. RatingControl.swift thêm vào dưới MARK properties
@IBInspectable var starSize: CGSize = CGSize(width: 44.0, height: 44.0)
@IBInspectable var starCount: Int = 5
  1. Sửa các hằng số đã set ở func setupButtons bằng các giá trị vừa khai báo
private func setupButtons() {
    
    for _ in 0..<starCount {
        // Create the button
        let button = UIButton()
        button.backgroundColor = UIColor.red
        
        // Add constraints
        button.translatesAutoresizingMaskIntoConstraints = false
        button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
        button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
        
        // Setup the button action
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
        
        // Add the button to the stack
        addArrangedSubview(button)
        
        // Add the new button to the rating button array
        ratingButtons.append(button)
    }
}

Giờ chúng ta hãy cùng mở lại Attributes inspector để thấy sự thay đổi. Star Size và Star Count đã được hiển thị và có thể thay đổi. 3. Để update control thì chúng ta phải reset control buttons mỗi khi mà attribute thay đổi. Để làm điều đó chúng ta cần thêm các property observer (người theo dõi các property) cho mỗi property. Property observers được gọi mỗi khi giá trị của property được set, nên nó được dùng để thực thi ngay tác vụ khi mà giá trị property thay đổi. Chúng ta chỉ cần sửa các properties đã khai báo như dưới đây.

@IBInspectable var starSize: CGSize = CGSize(width: 44.0, height: 44.0) {
    didSet {
        setupButtons()
    }
}
 
@IBInspectable var starCount: Int = 5 {
    didSet {
        setupButtons()
    }
}

didSet là hàm sẽ được gọi ra ngay sau khi giá trị của property bị thay đổi. Hàm setupButtons được sử dụng sẽ thêm button mới vào, tuy nhiên button cũ vẫn còn nên chúng ta cần xoá chúng đi. 4. Xoá các button cũ

// clear any existing buttons
for button in ratingButtons {
    removeArrangedSubview(button)
    button.removeFromSuperview()
}
ratingButtons.removeAll()

Hàm này xoá button ở list views được quản lý bởi stack view, tuy nhiên nó vẫn còn tồn tại ở subview của stack view nên cần xoá hẳn. Cuối cùng là xoá hoàn toàn cả mảng ratingButtons. Giờ chúng ta có hàm setupButtons như sau.

private func setupButtons() {
    
    // clear any existing buttons
    for button in ratingButtons {
        removeArrangedSubview(button)
        button.removeFromSuperview()
    }
    ratingButtons.removeAll()
    
    for _ in 0..<starCount {
        // Create the button
        let button = UIButton()
        button.backgroundColor = UIColor.red
        
        // Add constraints
        button.translatesAutoresizingMaskIntoConstraints = false
        button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
        button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
        
        // Setup the button action
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
        
        // Add the button to the stack
        addArrangedSubview(button)
        
        // Add the new button to the rating button array
        ratingButtons.append(button)
    }
}

Checkpoint: Mở Main.storyboard và thay đổi giá trị của Star Size, chúng ta sẽ tâhys tren canvas thể hiện được sự thay đổi tương ứng.

V. Thêm ảnh ngôi sao vào cho Buttons

Các ánh sao được dùng:

  1. EmptyStar
  2. FilledStar
  3. HighlightedStar

Các bạn thêm vào Assets.xcassets theo hướng dẫn từ các phần trước.

Thêm ảnh star cho button

  1. RatingControl.swift vào func setupButtons() và thêm đoạn code sau ngay trước vòng lặp for
// Load Button Images
let bundle = Bundle(for: type(of: self))
let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
  1. Tìm dòng set màu background màu đỏ và thay nó bằng set ảnh cho button
// Set the button images
button.setImage(emptyStar, for: .normal)
button.setImage(filledStar, for: .selected)
button.setImage(highlightedStar, for: .highlighted)
button.setImage(highlightedStar, for: [.highlighted, .selected])

Mỗi button sẽ có các trạng thái: normal, highlighted, focused, selected, và disabled. Chúng ta set mỗi ảnh với trạng thái tương ứng muốn hiển thị bằng đoạn code bên trên. Chú ý rằng button có thể ở 2 trạng thái cùng một lúc. setupButtons func của chúng ta sẽ như sau:

private func setupButtons() {
    
    // Clear any existing buttons
    for button in ratingButtons {
        removeArrangedSubview(button)
        button.removeFromSuperview()
    }
    ratingButtons.removeAll()
    
    // Load Button Images
    let bundle = Bundle(for: type(of: self))
    let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
    let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
    let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
    
    for _ in 0..<starCount {
        // Create the button
        let button = UIButton()
        
        // Set the button images
        button.setImage(emptyStar, for: .normal)
        button.setImage(filledStar, for: .selected)
        button.setImage(highlightedStar, for: .highlighted)
        button.setImage(highlightedStar, for: [.highlighted, .selected])
        
        // Add constraints
        button.translatesAutoresizingMaskIntoConstraints = false
        button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
        button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
        
        // Setup the button action
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
        
        // Add the button to the stack
        addArrangedSubview(button)
        
        // Add the new button to the rating button array
        ratingButtons.append(button)
    }
}

Checkpoint: Chạy app và click thử nhé.

VI. Viết Action cho Button

Xử lý hàm ratingButtonTapped

  1. Tìm hàm ratingButtonTapped(button:) trong RatingControl.swift
  2. Thay đoạn code in text bằng đoạn code xử lý như sau
func ratingButtonTapped(button: UIButton) {
    guard let index = ratingButtons.index(of: button) else {
        fatalError("The button, \(button), is not in the ratingButtons array: \(ratingButtons)")
    }
    
    // Calculate the rating of the selected button
    let selectedRating = index + 1
    
    if selectedRating == rating {
        // If the selected star represents the current rating, reset the rating to 0.
        rating = 0
    } else {
        // Otherwise set the rating to the selected star
        rating = selectedRating
    }
}

Ý tưởng là ta sẽ lấy index của button được chạm (báo lỗi nếu không tìm thấy). Nếu chọn vào button thể hiện rating cũng thì reset về 0. Nếu là giá trị mới thì cập nhật. 3. Sau khi đã lấy được rating phù hợp với button được chọn, ta cần update lại hiển thị của các button tương ứng với trạng thái của nó. Thêm method này ngay ở cuối của class.

private func updateButtonSelectionStates() {
    for (index, button) in ratingButtons.enumerated() {
        // If the index of a button is less than the rating, that button should be selected.
        button.isSelected = index < rating
    }
}

Nếu index của button mà nhỏ hơn rating thì các button đấy đều phải ở trạng thái được chọn. 5. Thêm property observer để gọi hàm updateButtonSelectionStates() mỗi khi giá trị rating thay đổi.

var rating = 0 {
    didSet {
        updateButtonSelectionStates()
    }
}

Method setupButtons của chúng ta giờ có dạng.

private func setupButtons() {
    
    // Clear any existing buttons
    for button in ratingButtons {
        removeArrangedSubview(button)
        button.removeFromSuperview()
    }
    ratingButtons.removeAll()
    
    // Load Button Images
    let bundle = Bundle(for: type(of: self))
    let filledStar = UIImage(named: "filledStar", in: bundle, compatibleWith: self.traitCollection)
    let emptyStar = UIImage(named:"emptyStar", in: bundle, compatibleWith: self.traitCollection)
    let highlightedStar = UIImage(named:"highlightedStar", in: bundle, compatibleWith: self.traitCollection)
    
    for index in 0..<starCount {
        // Create the button
        let button = UIButton()
        
        // Set the button images
        button.setImage(emptyStar, for: .normal)
        button.setImage(filledStar, for: .selected)
        button.setImage(highlightedStar, for: .highlighted)
        button.setImage(highlightedStar, for: [.highlighted, .selected])
        
        // Add constraints
        button.translatesAutoresizingMaskIntoConstraints = false
        button.heightAnchor.constraint(equalToConstant: starSize.height).isActive = true
        button.widthAnchor.constraint(equalToConstant: starSize.width).isActive = true
        
        // Setup the button action
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchUpInside)
        
        // Add the button to the stack
        addArrangedSubview(button)
        
        // Add the new button to the rating button array
        ratingButtons.append(button)
    }
    
    updateButtonSelectionStates()
}

Checkout: Run app và kiểm tra khi tap và một button bất kì.

VII. Kết nối Rating Control vào View Controller

Tương tự như các phần trước, chúng ta tiến hành theo các bước sau.

  1. Mở storyboard
  2. Chọn Assistant Editor
  3. Chọn RatingControl.
  4. Control-Drag vào phần property của ViewController.swift
  5. Ở mục Name trong pop-up được hiển thị chọn ratingControl Done! Checkpoint: Run app để check kết quả: