Most of the time, we build UI based on UIKit in an imperative way which means we have to create views and add them to their parents, then set up various properties to make them look like what we want. Each of them would be done by writing multiple statements. With the UI getting complex, it’s really a matter to identify what they are when we need to make changes.
Any other way to build UI in a more readable way?
From SwiftUI on iOS to Jetpack Compose on Android, the answer seems obvious. That’s declarative programming. SwiftUI is the official declarative framework for building UI on at least iOS 13. That’s to say that when we are working on an app that needs to support versions below iOS 13, or we have to build UI in Objective-C, it’s not available for us.
It’s a problem I run into when I was doing a refactoring job involving a complex page that should be implemented in Objective-C. My target is to build UI in a declarative way to make it maintainable and human-readable.
I admit the title of this article is a little bit overstated, but I think we can use a few codes to mimic the SwiftUI. Okay, let’s start with 2 questions:
Why does the DSL make the UI more readable?
Mostly, DSL structure brings us a visual feeling, like the storyboard or nib. we can easily get what it looks like from the structure itself. With space indent and the inclusion relationship. we can easily get the view hierarchy.
How SwiftUI implements the DSL?
Except for resultBuilder which doesn’t exist in Objective-C, the main reason is the API style and the Layout Manager.
A Layout Manager is an essential object when building a UI in a DSL structure because it’s a view container in nature that consists of subviews and rules. It’s responsible for applying the rules to the subviews. Fortunately, UIKit provides UIStackView, it’s a Layout Manager similar to LinearLayout on Android. We don’t have to specify the constraints or relationships between subviews. Instead, we just add views into UIStackView, and it will automatically layout them (vertically or horizontally).
But with only the Layout Manager, we still have to create subviews one by one and add them to the Layout Manager in an imperative way. how to resolve it? that’s what I mentioned above: API style.
In Objective-C, we can’t create a view using syntax like Label()
in SwiftUI, we have to write like UILabel *label = [[UILabel alloc] init];
. Umm, it’s obvious which one is more concise and human-readable.
How to create a view like SwiftUI?
As we know, we can use C language in an Objective-C source file. and the Label() really looks like a C global function with a capitalized name:
UILabel *Label() {
return [[UILabel alloc] init];
}
For the sake of minimizing the scope of the global function. it’s better to define it as a static function:
static UILabel *Label() {
return [[UILabel alloc] init];
}
we know that one of the benefits of using DSL structure is that we don’t have to define properties to reference the views. so we can use a more common return type for all of them:
static UIView *Label() {
return [[UILabel alloc] init];
}
Okay. After combining them together, we can offer 2 common Layout Managers:
UIStackView *VStack(UIStackViewDistribution distribution, NSArray<UIView *> *subviews) {
UIStackView *v = [[UIStackView alloc] initWithArrangedSubviews:subviews];
v.axis = UILayoutConstraintAxisVertical;
v.alignment = UIStackViewAlignmentFill;
v.distribution = distribution;
return v;
}
UIStackView *HStack(UIStackViewDistribution distribution, NSArray<UIView *> *subviews) {
UIStackView *v = [[UIStackView alloc] initWithArrangedSubviews:subviews];
v.axis = UILayoutConstraintAxisHorizontal;
v.alignment = UIStackViewAlignmentFill;
v.distribution = distribution;
return v;
}
the VStack is used to place subviews vertically while the HStack is used to place subviews horizontally.
It’s worth noting that the enum of UIStackViewDistribution is too long, we can define some short macro for them so that they look more concise:
#define fill UIStackViewDistributionFill
#define equal_spacing UIStackViewDistributionEqualSpacing
/// ...
Demo Project
It seems like we have solved to core issues of building declarative UI. Let’s build a demo with it, we’ll see other issues we need to resolve. Let’s say we are building a simple view controller, which shows a photo profile view on the left, with a name and a birthday label on the right. Like this:
When we click the photo view, the text of the name label will change to “Yanbo Sha”.
As the screenshot above, it’s a left-to-right structure entirely and the right part is a vertical layout. Therefore, we write the code as below:
HStack(fill, @[
AvatarView(),
VStack(fill, @[
NameView(),
TimeView(),
])
])
Aha? It’s much better than the traditional way as below:
- (UIView *)buildView {
UIStackView *vstack = [UIStackView alloc] init];
UIStackView *hstack = [UIStackView alloc] init];
AvatarView *avatarView = [AvatarView alloc] init];
NameView *nameView = [NameView alloc] init];
TimeView *timeView = [TimeView alloc] init];
[vstack addArrangedSubview: nameView];
[vstack addArrangedSubview: timeView];
[hstack addArrangedSubview: avatarView];
[hstack addArrangedSubview: vstack];
/// many configuration here
return vstack;
}
If we use AutoLayout to build this UI, it’ll be more complex.
The other views like AvatarView
, NameView
, TimeView
can be implemented with global C functions too:
static UIView *AvatarView() {
UIImageView *img = [UIImageView alloc] init];
return img;
}
static UIView *NameView() {
UILabel *lbl = [UILabel alloc] init];
return lbl;
}
static UIView *TimeView() {
UILabel *lbl = [UILabel alloc] init];
return lbl;
}
Data Binding
Umm, seems we have made it? Not really. We’ve just used a declarative way to build the view layer, but whatever MVC, MVVM, or other architectural patterns, we have to set the views with data and update them when data changes.
Not like SwiftUI which features an automatic update system, we have to set and update on our own. but HOW?
Did you notice that updating States will trigger SwiftUI update is more like RACSignal in ReactiveCocoa? views need to subscribe to the RACSignal and update themself upon the relevant signal emit events.
So like the SwiftUI: Label(State)
, we can offer the API like Label(RACSignal *signal)
.
Before moving forward, we need to define a ViewModel first because this demo project is built on MVVM:
@interface ViewModel : NSObject
#pragma mark - Output
@property (nonatomic, copy, readonly) NSString *avatarUrl;
@property (nonatomic, copy, readonly) NSString *name;
@property (nonatomic, copy, readonly) NSString *time;
#pragma mark - Input
- (void)updateName:(NSString *)name;
@end
@interface ViewModel ()
@property (nonatomic, copy, readwrite) NSString *avatarUrl;
@property (nonatomic, copy, readwrite) NSString *name;
@property (nonatomic, copy, readwrite) NSString *time;
@end
@implementation ViewModel
- (instancetype)init {
self = [super init];
if (self) {
/// load mock data
self.name = @"Rock";
self.avatarUrl = @"https://miro.medium.com/v2/resize:fill:100:100/1*kOyC7Snkp0xpEFJCdIO4Yg.jpeg";
self.time = @"Born in: 1992.11.23";
}
return self;
}
- (void)updateName:(NSString *)name {
self.name = name;
}
@end
ViewModel offers outputs used by the views. Compared with SwiftUI, instead of directly passing the property values of ViewModel to the views, we pass the observable RACSignal. The individual view is responsible for observing the RACSignal and updating itself upon changes happen:
HStack(fill, @[
AvatarView(RACObserve(self.viewModel, avatarUrl)),
VStack(fill, @[
NameView(RACObserve(self.viewModel, name)),
TimeView(RACObserve(self.viewModel, time)),
])
])
static UIView *AvatarView(RACSignal *observer) {
UIImageView *img = [UIImageView alloc] init];
[observer subscribeNext:^(id x) {
[img loadImageWithUrl:x];
}];
return image;
}
static UIView *NameView(RACSignal *observer) {
UILabel *lbl = [UILabel alloc] init];
[observer subscribeNext:^(id x) {
lbl.text = x;
}];
return lbl;
}
static UIView *TimeView(RACSignal *observer) {
UILabel *lbl = [UILabel alloc] init];
[observer subscribeNext:^(id x) {
lbl.text = x;
}];
return lbl;
}
The last work we need to do is to add an action handler to the photo profile view and call the updateName method on ViewModel to make the UI updates.
User Action
SwiftUI offers view modifiers like onTapGesture
, but I think this kind of code shouldn’t be in the DSL structure. because it will make the DSL hard to read. I’d like to put them inside the individual definition of views. Like below:
static UIView *AvatarView(RACSignal *observer) {
UIImageView *img = [UIImageView alloc] init];
img.userInteractionEnabled = YES;
UITapGestureRecognizer *tap = [UITapGestureRecognizer new];
[img addGestureRecognizer:tap];
[[tap rac_gestureSignal] subscribeNext:^(id x) {
[self.viewModel updateName: @"Yanbo Sha"];
}];
[observer subscribeNext:^(id x) {
[img sd_setImageWithURL:[NSURL URLWithString:x]];
}];
return img;
}
This’s what we want, but do you find any issue with the code block above? self
is not available inside C global function. For this reason, I prefer passing the ViewModel or the ViewController instead of a single signal. so we update the code as below:
@interface ViewController ()
@property (nonatomic, strong) ViewModel *viewModel;
@end
@implementation ViewController
- (void)viewDidLoad {
/// ViewModel
ViewModel *vm = [[ViewModel alloc] init];
self.viewModel = vm;
/// View
UIView *v = HStack(fill, @[
AvatarView(vm),
VStack(fill, @[
NameView(vm),
TimeView(vm),
])
]);
[self.view addSubview:v];
[v mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
[make install];
}];
}
@end
static UIView *AvatarView(ViewModel *viewModel) {
UIImageView *img = [UIImageView alloc] init];
img.userInteractionEnabled = YES;
UITapGestureRecognizer *tap = [UITapGestureRecognizer new];
[img addGestureRecognizer:tap];
[[tap rac_gestureSignal] subscribeNext:^(id x) {
[viewModel updateName: @"Yanbo Sha"];
}];
[RACObserve(viewModel, avatarUrl) subscribeNext:^(id x) {
[img sd_setImageWithURL:[NSURL URLWithString:x]];
}];
return img;
}
static UIView *NameView(ViewModel *viewModel) {
UILabel *lbl = [UILabel alloc] init];
[RACObserve(viewModel, name) subscribeNext:^(id x) {
lbl.text = x;
}];
return lbl;
}
static UIView *TimeView(ViewModel *viewModel) {
UILabel *lbl = [UILabel alloc] init];
[RACObserve(viewModel, time) subscribeNext:^(id x) {
lbl.text = x;
}];
return lbl;
}
Tunning
We've done the most of work for this demo project, but it’s still not what the screenshot looks like if you run it on the device. It probably looks like this:
Compared with the ultimate design, you’ll find the cause that we don’t apply any padding between these views.
In SwiftUI, we can use the padding view modifier to ask the Layout Manager to make some room for the padding when performing the layout. in the UIKit, the UIStackView offers customSpacingAfterView:
and setCustomSpacing:afterView:
to allow us to set space between subviews. but I choose another way to achieve it:
UIView *HSpace(CGFloat width) {
UIView *v = [[UIView alloc] init];
[v.widthAnchor constraintEqualToConstant:width].active = YES;
return v;
}
UIView *VSpace(CGFloat height) {
UIView *v = [[UIView alloc] init];
[v.heightAnchor constraintEqualToConstant:height].active = YES;
return v;
}
UIView *AnySpace() {
return [[UIView alloc] init];
}
Yup, it’s not a performance way but simple for this demo project. you can optimize it with the UIStackView API later. so let’s apply them to the project:
HStack(fill, @[
AvatarView(vm),
HSpace(8),
VStack(fill, @[
VSpace(20),
NameView(vm),
AnySpace(),
TimeView(vm),
VSpace(20),
])
]);
Well done! BTW, when we layout UI elements, we can also consider using UIStackView’s distribution and alignment to achieve the goals in a more performance way (better than the V/H/AnySpace
I wrote above)
Conclusion
Okay, that’s all. We have completed the demo project in a “SwiftUI” way. you can access the completed demo project from the link below: