Y-CoreUI
用于加速在代码中构建用户界面的 iOS 核心组件。

这个轻量级框架主要包括

它还包含其他 Foundation 和 UIKit 的各种扩展。

许可

Y—CoreUI 采用 Apache 2.0 许可

文档

文档自动从源代码注释生成,并呈现为通过 GitHub Pages 托管的静态网站,地址为:https://yml-org.github.io/YCoreUI/

用法

1. 用于声明式 Auto Layout 的 UIView 扩展

为了辅助自动布局,Y—CoreUI 具有多个 UIView 扩展,可以简化布局约束的创建。 这些扩展没有使用任何第三方库(例如 SnapKit),而只是 Apple 自己的 NSLayoutConstraint API 的包装器。 如果您更喜欢直接使用 Apple 的布局约束 API,那么完全可以使用它们。 但是,这些便捷方法可以减少代码量,并更直接地表达意图。

所有扩展都应用于 UIView,并且以 constrain 开头。

最简单的形式是仅使用属性创建约束,就像最初的 iOS 6 NSLayoutContraint API 一样。

// constrain a button's width to 100
let button = UIButton()
addSubview(button)
button.constrain(.width, constant: 100)

// constrain view to superview
let container = UIView()
addSubview(container)
container.constrain(.leading, to: .leading, of: superview)
container.constrain(.trailing, to: .trailing, of: superview)
container.constrain(.top, to: .top, of: superview)
container.constrain(.bottom, to: .bottom, of: superview)

另一种形式是使用锚点创建约束,就像 iOS 9 中首次引入的锚点 API 一样。

// constrain a button's width to 100
let button = UIButton()
addSubview(button)
button.constrain(.widthAnchor, constant: 100) 

// constrain view to superview
let container = UIView()
addSubview(container)
container.constrain(.leadingAnchor, to: leadingAnchor)
container.constrain(.trailingAnchor, to: trailingAnchor)
container.constrain(.topAnchor, to: topAnchor)
container.constrain(.bottomAnchor, to: bottomAnchor)

有一些重载可以处理将一个视图放置在另一个视图下方或另一个视图的尾侧的常见用例。

// constrain button2.leadingAnchor to button1.trailingAnchor
button2.constrain(after: button1, offset: insets.leading)

// constrain label2.topAnchor to label1.bottomAnchor
label2.constrain(below: label1, offset: gap)

但这些扩展真正发挥作用的是 constrainEdges 方法,只需一次方法调用即可创建最多四个约束。

// constrain 2 buttons across in a view
let button1 = UIButton()
let button2 = UIButton()
let insets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
addSubview(button1)
addSubview(button2)

button1.constrainEdges(.notTrailing, with: insets)
button2.constrainEdges(.notLeading, with: insets)
button2.constrain(after: button1, offset: insets.leading)
button1.constrain(.widthAnchor, to: button2.widthAnchor)

// constrain view to superview
let container = UIView()
addSubview(container)
container.constrainEdges()

还有一个 constrainEdgesToMargins 变体,用于在接收器的边缘和指定视图的布局边距(通常是接收器的父视图)之间设置约束。 这对于避免安全区域(例如导航栏或 FaceID 刘海所占据的区域)非常有用。

// constrain 2 buttons across in a view using margins
let button1 = UIButton()
let button2 = UIButton()
let spacing: CGFloat = 16
addSubview(button1)
addSubview(button2)

button1.constrainEdgesToMargins(.notTrailing)
button2.constrainEdgesToMargins(.notLeading)
button2.constrain(after: button1, offset: spacing)
button1.constrain(.widthAnchor, to: button2.widthAnchor)

// constrain view to superview margins
let container = UIView()
addSubview(container)
container.constrainEdgesToMargins()

约束大小

有三种便捷方法可以约束视图的大小。

// constrain a button's size to 44 x 44 (3 different ways)
let button = UIButton()
addSubview(button)

button.constrainSize(CGSize(width: 44, height: 44))
button.constrainSize(width: 44, height: 44)
button.constrainSize(44)

约束中心

有一个用于对齐视图的 Auto Layout 便捷方法。

// center a container view to its superview
let container = UIView()
addSubview(container)

container.constrainCenter()

// center a button horizontally
let button = UIButton()
addSubview(button)

button.constrainCenter(.x)

// align a button and a label vertically by their centers
let button = UIButton()
let label = UILabel()
addSubview(button)
addSubview(label)

button.constrainCenter(.y, to: label)

约束宽高比

有一个用于约束宽高比的 Auto Layout 便捷方法。

// constrain to a 16:9 aspect ratio
mediaPlayer.constrainAspectRatio(16.0 / 9)

// constrain to a 1:1 aspect ratio
profileImage.constrainAspectRatio(1)

2. 辅助加载字符串、颜色和图像资源的协议

我们有一些扩展可以加速字符串、颜色和图像的加载(并使其易于单元测试)。

Localizable

轻松地从任何基于字符串的 Enum 加载本地化字符串资源。 您所需要做的就是声明符合 Localizable 协议,您就可以访问 localized: String 属性。

// Conform your Enum to Localizable
enum SettingConstants: String, Localizable, CaseIterable {
    case title = "Settings_Title"
    case color = "Settings_Color"
}

// Then access the localized string
label.text = SettingsConstants.title.localized

单元测试也很容易

func test_Setting_Constants_loadsLocalizedString() {
    SettingConstants.allCases.forEach {
        // Given a localized string constant
        let string = $0.localized
        // it should not be empty
        XCTAssertFalse(string.isEmpty)
        // and it should not equal its key
        XCTAssertNotEqual($0.rawValue, string)
    }
}

该协议还允许您指定包含本地化字符串的 bundle 和可选的表名。

Colorable

轻松地从任何基于字符串的 Enum 加载颜色资源。 您所需要做的就是声明符合 Colorable 协议,您就可以访问 color: Color 属性。 您甚至可以定义一个 fallbackColor 来代替 nil.clear,这样在发生故障时,UI 元素也不会不可见(但默认情况下它们是鲜艳的粉红色,以便引起您的注意)。

// Conform your Enum to Colorable
enum PrimaryColors: String, CaseIterable, Colorable {
        case primary50
        case primary100
}

// Then access the color
label.textColor = PrimaryColors.primary50.color

单元测试很容易

func test_PrimaryColors_loadsColor() {
    PrimaryColors.allCases.forEach {
        XCTAssertNotNil($0.loadColor())
    }
}

该协议还允许您指定包含颜色资源的 bundle、可选的命名空间和 fallback 颜色。

ImageAsset

轻松地从任何基于字符串的 Enum 加载图像资源。 您所需要做的就是声明符合 ImageAsset 协议,您就可以访问 image: UIImage 属性。 您甚至可以定义一个 fallbackImage 来代替 nil,这样在发生故障时,UI 元素也不会不可见(但默认情况下它是一个鲜艳的粉红色正方形,以便引起您的注意)。

// Conform your Enum to ImageAsset
enum Flags: String, ImageAsset {
    case unitedStates = "flag_us"
    case india = "flag_in"
}

let flag: Flags = .india
// Then access the image
let image: UIImage = flag.image

如果您将 CaseIterable 添加到您的枚举中,那么编写单元测试以确保它们正常工作(并且您可以添加、更新、修改枚举 cases 而无需更新您的单元测试)就会非常简单。

enum Icons: String, CaseIterable, ImageAsset {
    case value1
    case value2
    ...
    case valueLast
}

func test_iconsEnum_loadsImage() {
    Icons.allCases.forEach {
        XCTAssertNotNil($0.loadImage())
    }
}

该协议还允许您指定包含图像资源的 bundle、可选的命名空间和 fallback 图像。

SystemImage

轻松地从任何基于字符串的 Enum 加载系统图像 (SF Symbols)。 您所需要做的就是声明符合 SystemImage 协议,您就可以访问 image: UIImage 属性。 与上面的 ImageAsset 类似,您可以定义一个 fallbackImage

既然它只是包装了 UIImage(systemName:),为什么还要费心这样做? 因为

  1. UIImage(systemName:) 返回 UIImage?,而 SystemImage.image 返回 UIImage
  2. 默认情况下,SystemImage.image 返回随动态类型缩放的图像。
  3. 将您的系统图像组织到枚举中可以鼓励更好的架构(并有助于避免字符串类型错误)。
  4. 更容易进行单元测试。
// Conform your Enum to SystemImage
enum Checkbox: String, SystemImage {
    case checked = "checkmark.square"
    case unchecked = "square"
}

// Then access the image
button.setImage(Checkbox.unchecked.image, for: .normal)
button.setImage(Checkbox.checked.image, for: .selected)

如果您将 CaseIterable 添加到您的枚举中,那么编写单元测试以确保它们正常工作(并且您可以添加、更新、修改枚举 cases 而无需更新您的单元测试)就会非常简单。

enum Checkbox: String, CaseIterable, SystemImage {
    case checked = "checkmark.square"
    case unchecked = "square"
}

func test_checkboxEnum_loadsImage() {
    Checkbox.allCases.forEach {
        XCTAssertNotNil($0.loadImage())
    }
}

3. 用于 WCAG 2.0 对比度计算的 UIColor 扩展

Y—CoreUI 包含许多扩展,可以更轻松地处理颜色。 其中最有用的可能是 WCAG 2.0 对比度计算。 给定任意两种颜色(代表前景色和背景色),您可以计算它们之间的对比度,并评估是否通过特定的 WCAG 2.0 标准(AA 或 AAA)。 您甚至可以编写单元测试来快速检查应用程序中所有颜色模式下的所有颜色对。 看起来像这样

final class ColorsTests: XCTestCase {
    typealias ColorInputs = (foreground: UIColor, background: UIColor, context: WCAGContext)

    // These pairs should pass WCAG 2.0 AA
    let colorPairs: [ColorInputs] = [
        // label on system background
        (.label, .systemBackground, .normalText),
        // label on secondary background
        (.label, .secondarySystemBackground, .normalText),
        // label on tertiary background
        (.label, .tertiarySystemBackground, .normalText),
        // secondary label on system background
        (.secondaryLabel, .systemBackground, .normalText),
        // secondary label on secondary background
        (.secondaryLabel, .secondarySystemBackground, .normalText),
        // secondary label on tertiary background
        (.secondaryLabel, .tertiarySystemBackground, .normalText),
        // tertiary label on system background
        (.tertiaryLabel, .systemBackground, .normalText),
        // tertiary label on secondary background
        (.tertiaryLabel, .secondarySystemBackground, .normalText),
        // tertiary label on tertiary background
        (.tertiaryLabel, .tertiarySystemBackground, .normalText),

        // system red on system background (fails)
        // (.systemRed, .systemBackground, .normalText),
    ]

    let allColorSpaces: [UITraitCollection] = [
        // Light Mode
        UITraitCollection(userInterfaceStyle: .light),
        // Light Mode, Increased Contrast
        UITraitCollection(traitsFrom: [
            UITraitCollection(userInterfaceStyle: .light),
            UITraitCollection(accessibilityContrast: .high)
        ]),
        // Dark Mode
        UITraitCollection(userInterfaceStyle: .dark),
        // Dark Mode, Increased Contrast
        UITraitCollection(traitsFrom: [
            UITraitCollection(userInterfaceStyle: .dark),
            UITraitCollection(accessibilityContrast: .high)
        ])
    ]

    func testColorContrast() {
        // test across all color modes we support
        for traits in allColorSpaces {
            // test each color pair
            colorPairs.forEach {
                let color1 = $0.foreground.resolvedColor(with: traits)
                let color2 = $0.background.resolvedColor(with: traits)

                XCTAssertTrue(
                    color1.isSufficientContrast(to: color2, context: $0.context, level: .AA),
                    String(
                        format: "#%@ vs #%@ ratio = %.02f under %@ Mode%@",
                        color1.rgbDisplayString(),
                        color2.rgbDisplayString(),
                        color1.contrastRatio(to: color2),
                        traits.userInterfaceStyle == .dark ? "Dark" : "Light",
                        traits.accessibilityContrast == .high ? " Increased Contrast" : ""
                    )
                )
            }
        }
    }
}

4. UIScrollView 扩展,以辅助避免键盘

FormViewController

FormViewController 是一个具有可滚动内容区域的视图控制器,可以自动为您避免键盘。 对于具有输入的视图(例如,登录或 onboarding)来说,这是一个不错的选择。 即使对于没有输入的视图,它对于管理 UIScrollView 的创建和其中设置的 contentView 仍然非常有用,因此您可以专注于您的内容,而无需为每个视图编写 scrollView 代码。

UIScrollView 扩展

想要拥有一个可以避免键盘的 scrollview,但您无法使用 FormViewController? 它的大部分功能只是 UIScrollView 的一个简单扩展。 您可以像这样将键盘避免功能添加到任何滚动视图:

scrollView.registerKeyboardNotifications()
💡 由于本地化、动态类型、潜在的小屏幕尺寸和横向模式支持的不可预测性,您的应用程序中几乎每个包含任何文本的全屏视图都应该是垂直滚动视图。

5. 其他

Elevation

Elevation 是一个模型对象,用于定义阴影,类似于 W3C box-shadowsFigma drop shadows。 它具有以下参数,这些参数与 Figma(和 web)定义 drop shadows 的方式相匹配

Elevation 具有一个 apply 方法,该方法然后将阴影效果应用于 CALayer。 记住每次颜色模式更改时都调用它,以更新阴影颜色 (CGColor)。

let button = UIButton()
let elevation = Elevation(
    xOffset: 0,
    yOffset: 2,
    blur: 5,
    spread: 0,
    color: .black,
    opacity: 0.5
)
elevation.apply(layer: button.layer, cornerRadius: 8)

Animation

Animation 是一个模型对象,用于定义 UIView 动画。 它具有以下参数

Animation.curve 是一个具有关联值的枚举,可以是 .regular.spring

有一个 UIView 类重写方法 animate,它接受一个 Animation 对象。

采用 Animation 结构的好处是,使用一个方法,您可以对常规或 spring 动画进行动画处理。 这允许我们构建组件,用户可以自定义使用的动画,而无需使我们的代码过于复杂或脆弱。

let button = UIButton()
button.alpha = 1
let animation = Animation(duration: 0.25, curve: .regular(options: .curveEaseOut))

UIView.animate(with: animation) {
    // fade button out
    button.alpha = 0
} completion: {
    // remove it from the superview when done
    button.removeFromSuperview()
}

安装

您可以通过将 Y-CoreUI 作为包依赖项添加到 Xcode 项目中来添加它。

  1. 文件菜单中,选择添加包...
  2. 在包存储库 URL 文本字段中输入“https://github.com/yml-org/YCoreUI
  3. 单击添加包

为 Y-CoreUI 做出贡献

要求

SwiftLint (linter)

brew install swiftlint

Jazzy (文档)

sudo gem install jazzy

设置

克隆 repo 并在 Xcode 中打开 Package.swift

版本控制策略

我们使用 语义版本控制

{major}.{minor}.{patch}

例如

1.0.5

分支策略

我们为我们的框架使用简化的分支策略。

分支命名约定

feature/{ticket-number}-{short-description}
bugfix/{ticket-number}-{short-description}

例如

feature/CM-44-button
bugfix/CM-236-textview-color

Pull Requests

在提交 pull request 之前,您应该

  1. 编译并确保没有警告和错误。
  2. 运行所有单元测试并确认一切通过。
  3. 检查单元测试覆盖率并确认所有新的/修改的代码都已完全覆盖。
  4. 从命令行运行 swiftlint 并确认没有违规行为。
  5. 从命令行运行 jazzy 并确认您有 100% 的文档覆盖率。
  6. 考虑使用 git rebase -i HEAD~{commit-count} 将您最近的 {commit-count} 个提交合并成功能块。
  7. 如果父分支(通常是 main)的 HEAD 自您创建分支以来已更新,请使用 git rebase main 来变基您的分支。
    • 永远不要将父分支合并到您的分支中。
    • 始终从父分支变基您的分支。

提交 pull request 时

合并 pull request 时

发布新版本

生成文档 (通过 Jazzy)

你可以直接从源代码生成你自己的本地文档集,只需在终端中使用以下命令:

jazzy

这会在 /docs 目录下生成一套文档。默认配置位于默认配置文件 .jazzy.yaml 中。

要查看更多文档选项,请输入:

jazzy --help

每次向 main 分支推送提交时,GitHub Action 会自动运行 Jazzy,为我们的 GitHub 页面生成文档,地址为:https://yml-org.github.io/YCoreUI/