You know what, I want to create components that are reusable. You know, wherever in your production App you use them you are going to use a UIButton subclass that has a few states, and is reliable.
But how am I going to structure the component before I get knee-deep in a particular implementation?
What if I create a UILabel as a great example.
You're reading the output of that investigation!
Difficulty: Beginner | Easy | Normal | Challenging
This article has been developed using Xcode 12.5, and Swift 5.4
- You will be expected to be aware how to make a Single View Application in Swift.
- Familiarity with the following concepts are required: typealias, some combine, extensions, intrinsicContentSize , getters and setters, clickable link
Model: Where data, and logic that manipulates the data is stored. Perhaps model objects, or networking code is stored here.Think of this as the what of the App.
I'm going to have a Model for my Label, which will provide the information that my Label uses to display the, well in this case - text. Much like model objects in the application this provides the data that is required for the view to display.
The view itself will be surfaced in the viewController (which is lucky since the viewController should control the view)
viewModel.$labelModel
.withUnretained(label)
.sink {_ in } receiveValue: { (label, model) in
label.update(with: model)
}
.store(in: &subscriptions)so we are surfacing both the label and the label model into the view controller (which will actually be in the viewDidLoad function in the ViewController), so we can use the previous ViewModel in a way that will allow us to update it without having to retain the viewmodel in the view controller - which will be nice!
Can we achieve this? Well, let us realise that I wouldn't have published this if I wasn't aware this was already gonig to work!
That model is going to be surfaced, along with the view in our view controller.
The model is reproduced here, as well as in full below to help you out:
extension Label {
public final class Model {
public typealias View = Label
public var text: String?
public init(
text: String? = nil
) {
self.text = text
}
}
}The label itself is a subclass of UILabel, and perhaps the most important part is that we have a function to update the Label with a model:
override public var text: String? {
get { super.text }
set { super.text = newValue }
}
public func update(with model: Label.Model) {
text = model.text
}Now there is some detail there with a clickable link which you can skip (it's all in an extension anyway) and still gain full understanding of this code. However, I've also overridden intrinsicContentSize to set the natural size for the receiving view.
public override var intrinsicContentSize: CGSize {
guard let text = text, !text.isEmpty else { return .zero }
let size = super.intrinsicContentSize
return .init(
width: size.width,
height: size.height
)
}Effectively we are passing on the size to the superclass, and if there is no text we return a bounding box of zero.
When should we initialize this viewmodel, and where? We should do that in our ViewModel, and initialize our labelModel when the viewcontroller is ready for it after viewDidLoad() has been hit from the ViewController.
Therefore I'm going to need therefore to know the LifecycleState of the UIViewController. I can write this as an extension on UIViewController.
public extension UIViewController {
enum LifecycleState: CaseIterable {
case didLoad
case willAppear
case didAppear
case willDisappear
case didDisappear
}
}We are going to need to listen to the changes in the Label, and we can do that using Combine's @Published which is then added in the ViewModel with the following:
class ViewModel {
@Published private(set) var labelModel: Label.Model = .init()
func didChange(state: UIViewController.LifecycleState) {
switch state {
case .didLoad:
labelModel = .init(text: "test")
break
default: break
}
}
}Phew. That's quite a great deal of stuff. Let's look at the code in full!
final class Label: UILabel {
public override var intrinsicContentSize: CGSize {
guard let text = text, !text.isEmpty else { return .zero }
let size = super.intrinsicContentSize
return .init(
width: size.width,
height: size.height
)
}
override public var text: String? {
get { super.text }
set { super.text = newValue }
}
public func update(with model: Label.Model) {
text = model.text
}
private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTapOnLabel))
public var linkHandler: ((Int, NSRange) -> Void)? {
didSet {
if linkHandler != nil {
isUserInteractionEnabled = true
addGestureRecognizer(tap)
} else {
isUserInteractionEnabled = false
removeGestureRecognizer(tap)
}
}
}
}
private extension Label {
@objc func handleLinkTapOnLabel(tapGesture: UITapGestureRecognizer) {
guard let linkHandler = linkHandler,
let attributedText = attributedText,
let font = font else {
return
}
// Configure NSTextContainer
let textContainer = NSTextContainer(size: CGSize.zero)
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = lineBreakMode
textContainer.maximumNumberOfLines = numberOfLines
let labelSize = bounds.size
textContainer.size = labelSize
// Configure NSLayoutManager and add the text container
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
// Configure NSTextStorage and apply the layout manager
let textStorage = NSTextStorage(attributedString: attributedText)
textStorage.addAttribute(NSAttributedString.Key.font, value: font, range: NSMakeRange(0, attributedText.length))
textStorage.addLayoutManager(layoutManager)
// get the tapped character location
let locationOfTouchInLabel = tapGesture.location(in: self)
let locationOfTouchInTextContainer = CGPoint(
x: locationOfTouchInLabel.x,
y: locationOfTouchInLabel.y
)
let characterIndex = layoutManager.characterIndex(
for: locationOfTouchInTextContainer,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil
)
attributedText.enumerateAttribute(
.link,
in: NSRange(location: 0, length: attributedText.length),
options: NSAttributedString.EnumerationOptions(rawValue: UInt(0)),
using: { (attrs: Any?, range: NSRange, _) in
if NSLocationInRange(characterIndex, range) {
linkHandler(characterIndex, range)
}
}
)
}
}extension Label {
public final class Model {
public typealias View = Label
public var text: String?
public init(
text: String? = nil
) {
self.text = text
}
}
}extension Publisher {
func withUnretained<Object: AnyObject>(_ obj: Object) -> AnyPublisher<(Object, Output), Error> {
return tryMap { [weak obj] element -> (Object, Output) in
guard let obj = obj else { throw UnretainedError.failedRetaining }
return (obj, element)
}
.eraseToAnyPublisher()
}
}
enum UnretainedError: Swift.Error {
case failedRetaining
}public extension UIViewController {
enum LifecycleState: CaseIterable {
case didLoad
case willAppear
case didAppear
case willDisappear
case didDisappear
}
}I'd love for you to take a look at the repo in full.
This is one way you might want to consider creating components in your project. Is it the only way? It isn't, but it is certainly one method of doing so, and I'd love to hear your thoughts about this, preferably on Twitter as you can guarantee I'll see it there.