Chào các bạn, hôm nay mình quay trở lại viết 1 bài blog để hướng dẫn các bạn 1 bài toán mà chắc rất nhiều bạn gặp phải khi đang code dự án iOS từ đầu hay đang maintain 1 dự án. Không dài dòng nữa, mình xin được bắt đầu.

I. Bài toán

Mới chập chững bước vào dự án mới về app Mobile viết bằng ngôn ngữ Swift. Vào một hôm đẹp trời, mình đang ngồi nhâm nhi cốc cà phê thì được giao cho task: Làm tất cả các text sử dụng trong app có thể copy được. Mình nghĩ chắc cũng bình thường thôi. Trước có làm react-native mấy attribute của Text có hỗ trợ sẵn, chắc bên native kiểu gì cũng sẽ được hỗ trợ thôi. tuy nhiên dòng cuối cùng lại khiến mình 1 chút lo lắng. "Làm ngắn nhất có thể". Hầu như việc apply change cho tất cả các text thì ngoài việc sửa từng chỗ, mình chưa tìm ra được cách nào khác.

Sau một thời gian tìm hiểu, thì mình bắt đầu đổ mồ hôi hột :) Hầu hết các text ngắn, dài, lớn, nhỏ trong dự án đều sử dụng UILabel. Và mình có phân loại được các object Text mà iOS hỗ trợ:

  • UITextField: là một control hiển thị các văn bản có thể sửa được và gửi thông tin hoạt động đến đối tượng được hướng tới khi user nhất nút return.
  • UITextView: implement các vùng văn bản mà có thể scroll được và có thể nhiều dòng
  • UILabel: implement các đoạn read-only text.

Như vậy, UILabel chỉ hỗ trợ read-only text, khác với UITextField và UITextView có thể select text và copy.

II. Giải pháp

Vò đầu bứt tai nhiều cũng chán, sau khi tìm hiểu và nghiên cứu một số thư viện bên ngoài, mình đã tìm ra được 1 thư viện vừa ngắn gọn vừa dễ hiểu ( và theo mình nó cũng là thư viện tốt nhất đối với bài toán của mình ). Các bạn có thể tham khảo UILabel-Copyable. Tuy nhiên doc của thư viện này khá sơ sài. Nên mình sẽ chia sẻ cách mình implement và sử dụng nó:

1. Installation

Do dự án đang sử dụng Carthage, mình không sử dụng cách truyền thống thông qua CocoaPods. Hơn nữa trong thư viện còn có 1 số ví dụ theo mình nghĩ là không cần thiết. Nên mình implement nó theo cách cổ điển.

Tạo file UILabel+Copyable.swift:

import UIKit

extension UILabel {
    private struct AssociatedKeys {
        static var isCopyingEnabled: UInt8 = 0
        static var shouldUseLongPressGestureRecognizer: UInt8 = 1
        static var longPressGestureRecognizer: UInt8 = 2
    }

    /// Set this property to `true` in order to enable the copy feature. Defaults to `false`.
    @IBInspectable var copyable: Bool {
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.isCopyingEnabled, newValue, .OBJC_ASSOCIATION_ASSIGN)
            self.setupGestureRecognizers()
        }
        get {
            let value = objc_getAssociatedObject(self, &AssociatedKeys.isCopyingEnabled)
            return (value as? Bool) ?? false
        }
    }

    /// Used to enable/disable the internal long press gesture recognizer. Defaults to `true`.
    @IBInspectable var shouldUseLongPressGestureRecognizer: Bool {
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.shouldUseLongPressGestureRecognizer, newValue, .OBJC_ASSOCIATION_ASSIGN)
            self.setupGestureRecognizers()
        }
        get {
            return (objc_getAssociatedObject(self, &AssociatedKeys.shouldUseLongPressGestureRecognizer) as? Bool) ?? true
        }
    }

    @objc
    var longPressGestureRecognizer: UILongPressGestureRecognizer? {
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.longPressGestureRecognizer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.longPressGestureRecognizer) as? UILongPressGestureRecognizer
        }
    }

    @objc
    override open var canBecomeFirstResponder: Bool {
        return self.copyable
    }

    @objc
    override open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        // Only return `true` when it's the copy: action AND the `copyingEnabled` property is `true`.
        return (action == #selector(self.copy(_:)) && self.copyable)
    }

    @objc
    override open func copy(_ sender: Any?) {
        if self.copyable {
            let pasteboard = UIPasteboard.general
            pasteboard.string = text
        }
    }

    @objc internal func longPressGestureRecognized(gestureRecognizer: UIGestureRecognizer) {
        let textRect = self.textRect(forBounds: self.bounds, limitedToNumberOfLines: self.numberOfLines)

        if gestureRecognizer === longPressGestureRecognizer && gestureRecognizer.state == .began {
            self.becomeFirstResponder()

            let copyMenu = UIMenuController.shared
            copyMenu.arrowDirection = .default

            if #available(iOS 13.0, *) {
                copyMenu.showMenu(from: self, rect: textRect)
            } else {
                // Fallback on earlier versions
                copyMenu.setTargetRect(textRect, in: self)
                copyMenu.setMenuVisible(true, animated: true)
            }
        }
    }

    fileprivate func setupGestureRecognizers() {
        if let longPressGR = self.longPressGestureRecognizer {
            self.removeGestureRecognizer(longPressGR)
            self.longPressGestureRecognizer = nil
        }

        if self.shouldUseLongPressGestureRecognizer && self.copyable {
            self.isUserInteractionEnabled = true
            let longPressGR = UILongPressGestureRecognizer(target: self,
                                                           action: #selector(self.longPressGestureRecognized(gestureRecognizer:)))
            self.longPressGestureRecognizer = longPressGR
            self.addGestureRecognizer(longPressGR)
        }
    }
}

Các bạn nên implement file này trong thư mục mà các bạn khai báo dùng chung cho tất cả các file khác nhé (Common/Extension/Utils gì đó :D)

Về cấu trúc code cũng khá dễ hiểu, trong UILabel chúng ta sẽ tạo ra 1 số attributes mới, cung cấp bộ nhớ trên Ram cho nó. Đơn giản là vậy.

2. Cách sử dụng

a. UILabel nhất định

Việc này thì khá là đơn giản rồi, trong file ViewController của Storyboard hoặc file Xib, sau khi bạn khai báo Outlet cho UILabel thì chỉ cần gán thêm thuộc tính copyable cho nó.

...
class TicketDetailViewController: UIViewController {
	...
	@IBOutlet weak var priceLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.priceLabel.copyable = true
    }

    ...
}
Ví dụ với ViewController

b. Tất cả các UILabel

Như mình đã nói ở trên, requirement yêu cầu hãy làm bằng cách ngắn nhất có thể. 2 cách mình có thể nghĩ ra lúc đó:

  • Tìm tất cả các UILabel trong project và thêm copyable vào cho nó :D.
  • Tạo 1 subclass của UILabel ( CopyableLabel.swift chẳng hạn) , thêm copyable vào hàm khởi tạo, rồi khai báo nó thay cho UILabel.

Tuy nhiên 2 phương án trên không khả thi 1 chút nào. Mình không thể mở ra từng file, tìm kiếm UILabel rồi thêm copyable vào được. việc thay đổi có thể lên đến cả nghìn file. Việc tạo 1 subclass của UILabel rồi thêm thuộc tính copyable vào cũng chẳng khác gì cách đầu tiên. Việc làm cho UILabel có thể copy được mình đang làm thực chất là viết thêm extension cho UILabel. Extension thì không thể override lại các hàm khởi tạo của UILabel được.

Sau khi nghiên cứu mình cũng đã tìm ra 1 cách ngắn gọn, trong file UILabel+Copyable.swift, các bạn có thể override hàm layoutSubviews():

UILabel+Copyable.swift:

import UIKit

extension UILabel {
	....
	
    open override func layoutSubviews() {
        super.layoutSubviews()
        self.copyable = true
    }
}

Hàm layoutSubview() được sử dụng mỗi khi layout được update, vì vậy tất cả các UILabel đều được gắn thêm thuộc tính copyable. Tuy nhiên, có thể bạn không biết, tất cả các text của UIButton, UITabBar, Copy Menu(UIMenuController) đều sử dụng UILabel. Điều này khiến cho việc nhấn các UIButton,.. trở nên cực kì khó khăn. Nên mình handle vấn đề này bằng cách:

UILabel+Copyable.swift:

import UIKit

extension UILabel {
	....
	
    open override func layoutSubviews() {
        super.layoutSubviews()
        guard let superview = self.superview else { return }
        if self.superclass != UILabel.self, !superview.isKind(of: UIControl.self) {
            self.copyable = true
        }
    }
}

Sau khi nghiên cứu, mình phát hiện ra là các UILabel của các Buton, Tabbar,... thì các superclass của nó đều là UILabel, khác với việc sử dụng UILabel trực tiếp trên view. Đồng thời mình cũng handle trường hợp với UIControl (View có thể sử dụng như 1 Action).

3. Kết quả

Mình đã applied được đối với tất cả các UILabel trong dự án 1 cách ngắn gọn nhất. đúng như mong muốn.

III. Lời kết

Trên đây chỉ là cách mình đã sử dụng trong dự án hiện tại mình đang làm. Đó có thể chưa phải là cách tối ưu nhất. Nếu các bạn có cách khác hay hơn thì có thể chia sẻ cho mình nhé. Hẹn gặp lại mọi người trong các bài blog khác :D.