iOS 轻量级且可定制的样式表
您有一个应用程序。也许甚至是一系列应用程序。您了解 CSS,以及它是如何使网络开发人员能够编写一组声明性类来装饰其网站中的元素,从而创建与页面内容完全分离的可组合界面定义。您会承认您有点嫉妒在 iOS 上事情并不是完全相同。
要装饰您今天的应用程序,您可能有一个 MyAppStyle
单例,它提供装饰过的界面组件,并且几乎成为您的应用程序中每个视图控制器的依赖项。也许您使用了 Apple 的 UIAppearance
API,但您限于非常小的外观 API 的子集。也许您已经开始对 UIKit 类进行子类化,只是为了设置一些默认值以创建一些装饰过的组件。您知道这很糟糕,但在 iOS 中没有更好的做事方式。
好吧,事情即将改变。请看下面的示例,看看 Motif
可以为您做些什么
以下是一个使用 Motif 创建一对装饰按钮的简单示例。要继续阅读,您可以继续阅读下面或克隆此仓库,然后在 Motif.xcworkspace
中运行 ButtonsExample
目标。
您的设计师刚刚发送了一个关于应用程序中几个按钮样式的规范。由于您将使用 Motif 来创建这些组件,这意味着是时候创建一个主题文件了。
Motif 中的主题文件只是一种简单的以标记文件(YAML 或 JSON)编码的字典。此字典可以包含两种类型的键/值对:类 或 常量
.Button
),编码为键,带有值为嵌套字典的值,类是一组命名的属性,对应于一起定义您的界面中元素风格的值。类属性的值可以是任何类型,或者是另一个类或常量的引用。.Class:
property: value
$RedColor
)并以键值对形式编码,常量是对值的命名引用。常量值可以是任何类型,或者指向前一个类或常量的引用。$Constant: value
为了创建设计规范中的按钮,我们已经编写了以下主题文件
Theme.yaml
$RedColor: '#f93d38'
$BlueColor: '#50b5ed'
$FontName: AvenirNext-Regular
.ButtonText:
fontName: $FontName
fontSize: 16
.Button:
borderWidth: 1
cornerRadius: 5
contentEdgeInsets: [10, 20]
tintColor: $BlueColor
borderColor: $BlueColor
titleText: .ButtonText
.WarningButton:
_superclass: .Button
tintColor: $RedColor
borderColor: $RedColor
我们首先定义了三个常量:按钮颜色为$RedColor
和$BlueColor
,字体名称为$FontName
。我们选择将这些值定义为常量,因为我们要能够在我们的主题文件中通过其名称引用它们——就像Less或Sass中的变量。
我们现在声明第一个类:.ButtonText
。该类描述了按钮中文字的属性——包括其字体和大小。从现在起,我们将使用这个类来声明我们希望文本拥有这种特定的样式。
您会注意到.ButtonText
类中的fontName
属性的值是对之前声明的$FontName
常量的引用。正如您可以猜到的,当我们使用这个类时,它的字体名称将具有与$FontName
常量相同的值。这样,我们就能有一个明确的字体名称存在的地方,避免了重复。
最后一个声明的类是.WarningButton
类。这个类与之前的类完全相同,只有一个关键的区别:它通过_superclass
指令从.Button
类继承其属性。因此,当.WarningButton
类应用于接口组件时,它将具有与.Button
类相同的样式,除了tintColor
和borderColor
,这些属性它覆盖以$RedColor
代替。
接下来,我们将创建应用此主题到我们接口元素所必需的属性应用器。大多数情况下,Motif能够通过将Motif属性名称与Objective-C属性名称相匹配来自动确定如何应用您的主题。然而,在某些属性的情况下,我们必须自己声明它们的值应该如何应用。为此,我们将在一些分类的load
方法中注册我们的必要主题属性应用器。
我们创建的第一组属性应用器是在UIView
上
UIView+Theming.m
+ (void)load {
[self
mtf_registerThemeProperty:@"borderWidth"
requiringValueOfClass:NSNumber.class
applierBlock:^(NSNumber *width, UIView *view, NSError **error) {
view.layer.borderWidth = width.floatValue;
return YES;
}];
[self
mtf_registerThemeProperty:@"borderColor"
requiringValueOfClass:UIColor.class
applierBlock:^(UIColor *color, UIView *view, NSError **error) {
view.layer.borderColor = color.CGColor;
return YES;
}];
}
在这里,我们在UIView
上添加了两个属性应用器:一个用于borderWidth
,另一个用于borderColor
。由于UIView
没有这些属性的属性,我们需要属性应用器来教Motif如何将它们应用到底层的CALayer
上。
如果我们想确保我们始终将属性值应用于特定类型,我们可以在注册应用器时使用requiringValueOfClass:
来指定应用器需要一定类别的值。在上述borderWidth
属性的情况下,我们需要其值为NSNumber
类。这样,如果我们意外地为borderWidth
属性提供一个非数字值,将会抛出一个运行时异常,这样我们就可以轻松地识别并修复错误。
现在,我们在UIView
上定义了这两个属性,它们将在未来对具有相同属性的其他主题可用。因此,每当另一个类想在UIView
或其任何子类上指定一个borderColor
或borderWidth
时,这些应用器也将能够应用这些值。
接下来,我们将在UILabel
上添加一个应用器以风格化文本
UILabel+Theming.m
+ (void)load {
[self
mtf_registerThemeProperties:@[
@"fontName",
@"fontSize"
]
requiringValuesOfType:@[
NSString.class,
NSNumber.class
]
applierBlock:^(NSDictionary<NSString *, id> *properties, UILabel *label, NSError **error) {
NSString *name = properties[ThemeProperties.fontName];
CGFloat size = [properties[ThemeProperties.fontSize] floatValue];
UIFont *font = [UIFont fontWithName:name size:size];
if (font != nil) {
label.font = font;
return YES;
}
return [self
mtf_populateApplierError:error
withDescriptionFormat:@"Unable to create a font named %@ of size %@", name, @(size)];
}];
}
上述应用器是一个复合属性应用器。这意味着它需要一个主题类的多个属性值来应用。在这种情况下,我们之所以需要这样做,是因为我们无法在没有预先拥有字体大小和名称的情况下创建UIFont
对象。
我们需要最后的将在UIButton
上应用以美化其titleLabel
的应用器。
UIButton+Theming.m
+ (void)load {
[self
mtf_registerThemeProperty:@"titleText"
requiringValueOfClass:MTFThemeClass.class
applierBlock:^(MTFThemeClass *class, UIButton *button, NSError **error) {
return [class applyTo:button.titleLabel error:error];
}];
}
MTFThemeClass
值的属性之前,我们在UILabel
上创建了一个样式应用器,允许我们从主题类指定自定义字体。幸好事后我们能够使用这个应用器来美化我们的UIButton
的titleTabel
。因为.Button
类的titleText
属性是一个对.ButtonText
类的引用,我们知道通过这个应用器传入的值将属于MTFThemeClass
类。MTFThemeClass
对象用于表示从主题文件中像.TitleText
这样的类,并且能够直接将它们的属性应用到对象上。因此,要应用来自.TitleText
类的fontName
和fontSize
到我们的按钮的标签上,我们只需在传递的MTFThemeClass
上对titleLabel
调用applyToObject:
。
NSError *error;
MTFTheme *theme = [MTFTheme themeFromFileNamed:@"Theme" error:&error];
NSAssert(theme != nil, @"Error loading theme %@", error);
[theme applyClassWithName:@"Button" to:saveButton error:NULL];
[theme applyClassWithName:@"WarningButton" to:deleteButton error:NULL];
我们现在有风格的按钮以匹配规范所需的一切。为此,我们必须从主题文件实例化一个MTFTheme
对象以从我们的代码中访问我们的主题。最佳做法是使用themeFromFileNamed:
,它就像imageNamed:
一样工作,但是用于MTFTheme
而不是UIImage
。
当我们拥有我们的MTFTheme
时,我们想确保没有错误解析它。我们可以通过断言我们的按引用error
仍非nil来实现。通过使用NSAssert
,当我们在调试中时会收到有关我们错误的运行时崩溃通知(如有错误的话)。
要将Theme.yaml
中的类应用到我们的按钮上,我们只需在按钮上调用MTFTheme
的应用方法。当我们这样做时,所有主题类的属性都会应用到一个简单的调用中。这就是Motif的核心。
在您的项目中使用Motif的最佳方式是使用Carthage或CocoaPods。
将以下内容添加到您的Cartfile
中
github "EricHoracek/Motif"
将以下内容添加到您的Podfile
中
pod 'Motif'
Motif最强大的功能之一是它能够在运行时动态地为主题应用程序的用户界面。虽然采用动态主题很简单,但这确实需要您以不同于上述示例的方式使用Motif。
关于实际应用动态主题的示例,请克隆此存储库,然后在Motif.xcworkspace
中运行DynamicThemingExample
目标。
要启用动态主题,您通常会使用MTFTheme
上的主题类应用方法应用于单个主题,但在您希望有多个主题时,您应使用相同名称的MTFDynamicThemeApplier
上的方法。这样可以轻松地将新的主题重新应用到整个界面,只需更改MTFDynamicThemeApplier
上的theme
属性即可。
// We're going to default to the light theme
MTFTheme *lightTheme = [MTFTheme themeFromFileNamed:@"LightTheme" error:NULL];
// Create a dynamic theme applier, which we will use to style all of our
// interface elements
MTFDynamicThemeApplier *applier = [[MTFDynamicThemeApplier alloc] initWithTheme:lightTheme];
// Style objects the same way as we did with an MTFTheme instance
[applier applyClassWithName:@"InterfaceElement" to:anInterfaceElement error:NULL];
随后...
// It's time to switch to the dark theme
MTFTheme *darkTheme = [MTFTheme themeFromFileNamed:@"DarkTheme" error:NULL];
// We now change the applier's theme to the dark theme, which will automatically
// re-apply this new theme to all interface elements that previously had the
// light theme applied to them
[applier setTheme:darkTheme error:NULL];
虽然您可以通过维护多套不同的主题文件来为应用程序界面创建不同的主题,但实现动态主题的最佳(也是最简单)方法是通过整个应用程序大量共享相同的主题文件集合。实现这一点的一个简单方法是创建一组所谓的“映射”主题文件,这些文件将您的根常量从描述其外观的命名值映射到描述其功能的命名值。例如
Colors.yaml
$RedDarkColor: '#fc3b2f'
$RedLightColor: '#fc8078'
DarkMappings.yaml
$WarningColor: $RedLightColor
LightMappings.yaml
$WarningColor: $RedDarkColor
Buttons.yaml
.Button:
tintColor: $WarningColor
我们创建了一个单个常量,$WarningColor
,其值将根据我们用其创建MTFTheme
实例的映射主题文件而变化。如上所述,常量名称$WarningColor
描述了该颜色的功能,而不是其外观(例如,$RedColor
)。这种相同常量的重新定义使我们能够基于所使用的主题有条件地重定义$WarningColor
的含义。因此,我们不必担心维护多个相同.Button
类的定义,确保我们不重复自己,并保持代码整洁。
采用这种模式,创建浅色和深色主题就像这样
MTFTheme *lightTheme = *theme = [MTFTheme
themeFromFileNamed:@[
@"Colors",
@"LightMappings",
@"Buttons"
] error:NULL];
MTFTheme *darkTheme = *theme = [MTFTheme
themeFromFileNamed:@[
@"Colors",
@"DarkMappings",
@"Buttons"
] error:NULL];
这样,我们只需为应用程序中要添加的每个主题创建一次主题类,而不是为每个主题都创建一次。要深入了解这种模式,请克隆此存储库,并阅读Motif.xcworkspace
中DynamicThemingExample
目标的源代码。
在上例中,您可能已经注意到,Motif使用了很多字符串类型来将类和常量名称从您的主题文件桥接到应用程序代码中。如果您以前处理过类似系统(例如Core Data的实体和属性名称),您知道随着时间的推移,字符串类型接口会变得难以维护。幸运的是,正如存在Mogenerator来在处理Core Data时缓解此问题一样,也存在类似解决方案来解决Motif中相同的问题:Motif CLI。
Motif附带了命令行界面,该界面可以轻松确保您的代码始终与主题文件同步。让我们看看如何在您的应用程序中使用它的一个例子
您有一个简单的YAML主题文件,命名为Buttons.yaml
$RedColor: '#f93d38'
$BlueColor: '#50b5ed'
$FontName: AvenirNext-Regular
.ButtonText:
fontName: $FontName
fontSize: 16
.Button:
borderWidth: 1
cornerRadius: 5
contentEdgeInsets: [10, 20]
tintColor: $BlueColor
borderColor: $BlueColor
titleText: .ButtonText
.WarningButton:
_superclass: .Button
tintColor: $RedColor
borderColor: $RedColor
要从此主题文件生成主题符号,只需运行以下命令:
motif --theme Buttons.yaml
这将生成名为ButtonSymbols.{h,m}
的一对文件。ButtonSymbols.h
看起来是这样的:
extern NSString * const ButtonsThemeName;
extern const struct ButtonsThemeConstantNames {
__unsafe_unretained NSString *BlueColor;
__unsafe_unretained NSString *FontName;
__unsafe_unretained NSString *RedColor;
} ButtonsThemeConstantNames;
extern const struct ButtonsThemeClassNames {
__unsafe_unretained NSString *Button;
__unsafe_unretained NSString *ButtonText;
__unsafe_unretained NSString *WarningButton;
} ButtonsThemeClassNames;
extern const struct ButtonsThemeProperties {
__unsafe_unretained NSString *borderColor;
__unsafe_unretained NSString *borderWidth;
__unsafe_unretained NSString *contentEdgeInsets;
__unsafe_unretained NSString *cornerRadius;
__unsafe_unretained NSString *fontName;
__unsafe_unretained NSString *fontSize;
__unsafe_unretained NSString *tintColor;
__unsafe_unretained NSString *titleText;
} ButtonsThemeProperties;
现在,当您将上述一对文件添加到项目中后,您可以使用这些符号创建和应用主题:
#import "ButtonSymbols.h"
NSError *error;
MTFTheme *theme = [MTFTheme themeFromFileNamed:ButtonsThemeName error:&error];
NSAssert(theme != nil, @"Error loading theme %@", error);
[theme applyClassWithName:ButtonsThemeClassNames.Button to:saveButton error:NULL];
[theme applyClassWithName:ButtonsThemeClassNames.WarningButton to:deleteButton error:NULL];
如您所见,应用程序代码中现在不再需要字符串类型。要深入了解如何使用Motif CLI的示例,请查看Motif.xcworkspace
中的任何示例。
要安装Motif CLI,只需在Motif.xcworkspace
中构建并运行MotifCLI
目标。这将安装motif
CLI到您的/usr/local/bin
目录。
要自动化符号生成,请在应用程序的运行脚本构建阶段中添加以下脚本。此脚本假定所有主题文件都以Theme.yaml
结尾,但您可以根据需要对其进行修改。
export PATH="$PATH:/usr/local/bin/"
export CLI_TOOL='motif'
which "${CLI_TOOL}"
if [ $? -ne 0 ]; then exit 0; fi
export THEMES_DIR="${SRCROOT}/${PRODUCT_NAME}"
find "${THEMES_DIR}" -name '*Theme.yaml' | sed 's/^/-t /' | xargs "${CLI_TOOL}" -o "${THEMES_DIR}"
这将确保您的符号文件始终与主题文件同步。只需确保此运行脚本构建阶段位于项目中“编译源”构建阶段之前即可。要看到实际示例,请查看Motif.xcworkspace
中的任何示例项目。
要启用实时刷新,请在iOS模拟器上进行调试时,只需将您当前的MTFDynamicThemeApplier
替换为MTFLiveReloadThemeApplier
即可。
#if TARGET_IPHONE_SIMULATOR && defined(DEBUG)
self.themeApplier = [[MTFLiveReloadThemeApplier alloc]
initWithTheme:theme
sourceFile:__FILE__];
#else
self.themeApplier = [[MTFDynamicThemeApplier alloc]
initWithTheme:theme];
#endif
实时重新加载只能在iOS模拟器上工作,因为您的iOS设备上不存在可编辑的主题源文件。要深入了解如何实现实时重新加载,请克隆此仓库并阅读位于Motif.xcworkspace
中的DynamicThemingExample
目标的源代码。