子视图

...或者 UIKit 的光明新未来(也许)


注意
Subviews 使用的是属性包装器,而不是宏,因此它支持 Swift 5.5 以来的所有 Swift 版本

Subviews 是一个构建于 UIKit 之上的轻量级软件包,它提供了一种编写自定义视图子类的新方式,用更少的样板代码和更高的清晰度。

简而言之,它将所有这些代码转换成

final class HelloWorldView: UIView {
    let label = UILabel()
    
    init() {
        super.init(frame: .zero)
        configure()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configure() {
        label.text = "Hello, UIKit 2023"
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor),
            label.bottomAnchor.constraint(equalTo: bottomAnchor),
            label.leadingAnchor.constraint(equalTo: leadingAnchor),
            label.trailingAnchor.constraint(equalTo: trailingAnchor),
        ])
    }
}

这样

import Subviews

final class HelloWorldView: Superview {
    @Subview(.pin, {
        $0.text = "Hello, UIKit 2023"
    })
    var label = UILabel()
}

注意
帮助拯救乌克兰。通过 United24 捐款,这是乌克兰总统的官方筹款平台

展示

具有快速布局选项的最简单视图

final class EmptyStateView: Superview {
    @Subview([.alignCenterX, .alignCenterY(offset: -10)], {
        $0.text = "This list is empty"
    })
    var label = UILabel()
}

更高级的示例,使用堆栈视图和使用 self 的动态视图配置

final class EmojiLogoView: Superview {
    let republicName: String
    let republicEmoji: String
    
    @Subview(.pin, {
        $0.axis = .vertical
        $0.alignment = .center
    })
    var stackView = UIStackView()
    
    @ArrangedSubview(of: \.stackView, { (label, self) in
        label.font = .systemFont(ofSize: 60)
        label.text = self.republicEmoji
    })
    var emojiSeal = UILabel()
    
    @ArrangedSubview(of: \.stackView, { (label, self) in
        label.font = .systemFont(
            ofSize: 20,
            weight: .heavy,
            width: .condensed
        )
        label.text = self.republicName
    })
    var nameLabel = UILabel()
}

let republicOfBoba = EmojiLogoView(republicName: "REPUBLIC OF BOBA", republicEmoji: "🧋")

更高级的示例,包含更多快速布局选项和使用 self 的动态视图创建

final class BestFlagView: Superview {
    let republicName: String
    let republicEmoji: String
    
    @Subview([.pinTop, .pinHorizontally, .relativeHeight(0.8)], {
        $0.backgroundColor = .white
    })
    var whiteBackground = UIView()
    
    @Subview([.pinBottom, .pinHorizontally, .relativeHeight(0.2)], {
        $0.backgroundColor = .systemRed
    })
    var redStripe = UIView()
    
    @Subview(of: \.whiteBackground, [.alignCenterX, .pinBottom(inset: 4)])
    var logo = { (self) in
        EmojiLogoView(
            republicName: self.republicName,
            republicEmoji: self.republicEmoji
        )
    }
}

let bestFlag = BestFlagView(republicName: "CALIFORNIA BURRITO", republicEmoji: "🌯")

也适用于视图控制器

final class EmptyStateVC: ParentViewController {
    
    // use @Child to add child view controllers:
    @Child([.safeAreaPin], {
        $0.view.backgroundColor = .systemGray
    })
    var backgroundVC = UIViewController()
    
    // @Subview is also supported:
    @Subview(.pin)
    var emptyStateView = EmptyStateView()
    
}

安装

Swift 包管理器

  1. 点击 File → Swift Packages → Add Package Dependency。
  2. 输入 https://github.com/dreymonde/Subviews.git

指南

Superview / ParentViewController

首先,您应该始终使用 Superview(或您自己的继承自 Superview 的类)作为自定义 UIView 的基类(对于自定义视图控制器,使用 ParentViewController)。这将确保所有 @Subview@Child 属性都被正确添加。

注意
如果您不想更改您的基类,请参阅 在不继承 Superview 的情况下使用 @Subview

final class CustomView: Superview {
	// ...
}
final class CustomViewController: ParentViewController {
	// ...
}

添加 @Subviews

@Subviews 可以添加到视图以及视图控制器

final class CustomView: Superview {
    @Subview(.pin)
    var button = UIButton(type: .system)
}

如果您没有明确指定父视图,子视图将直接添加到 self (对于视图控制器,则是 self.view)。或者您可以使用 @Subview(of:) 来使用不同的父视图,从而创建视图层级结构

final class CustomView: Superview {
    @Subview(.pin) // added to `self`
    var background = UIView()
    
    // use the "\." keypath syntax!
    @Subview(of: \.background, .marginsPin) // added to `background`
    var button = UIButton(type: .system)
}

使用快速布局选项

Subviews 提供了许多方便易用的布局修饰符。它们都是 100% UIKit 并且基于自动布局

final class GreenFlagView: Superview {
    @Subview([
        .marginsPinHorizontally,
        .pinVertically(insets: .all(4)),
        .height(40),
        .aspectRatio(3.0/2.0)
    ])
    var rectangularFlag = RectangleView(color: .green)
}

所有可用快速布局选项的列表 (ViewLayoutOption 结构)

// Center:
// `offset` parameter is optional
.alignCenter(offset:)
.alignCenterX(offset:)
.alignCenterY(offset:)
 
// Size:
.size(_ size:)
.height(_ height:)
.width(_ width:)
.aspectRatio(_ widthToHeight:)
.aspectRatioSquare
.relativeSize(_ relativeSize:)
.relativeHeight(_ relativeHeight:)
.relativeWidth(_ relativeWidth:)
 
// Edges Pin:
// `insets` / `inset` parameter is optional
.pin(insets:)
.pin(inset:)
.pinHorizontally(insets:)
.pinVertically(insets:)
.pinBottom(inset:)
.pinTop(inset:)
.pinLeading(inset:)
.pinTrailing(inset:)
 
// Margins Pin:
// `insets` / `inset` parameter is optional
.marginsPin(insets:)
.marginsPin(inset:)
.marginsPinHorizontally(insets:)
.marginsPinVertically(insets:)
.marginsPinBottom(inset:)
.marginsPinTop(inset:)
.marginsPinLeading(inset:)
.marginsPinTrailing(inset:)
 
// Safe Area Pin:
// `insets` / `inset` parameter is optional
.safeAreaPin(insets:)
.safeAreaPin(inset:)
.safeAreaPinHorizontally(insets:)
.safeAreaPinVertically(insets:)
.safeAreaPinBottom(inset:)
.safeAreaPinTop(inset:)
.safeAreaPinLeading(inset:)
.safeAreaPinTrailing(inset:)
 
// Readable Content Guide Pin:
// `insets` / `inset` parameter is optional
.readableContentPin(insets:)
.readableContentPin(inset:)
.readableContentPinHorizontally(insets:)
.readableContentPinVertically(insets:)
.readableContentPinBottom(inset:)
.readableContentPinTop(inset:)
.readableContentPinLeading(inset:)
.readableContentPinTrailing(inset:)

您可以将一个或多个布局选项与 @Subview@Child 一起使用

@Subview(.alignCenter)
@Subview([.pinTop, .pinBottom, .marginPinLeading])
@Child(.safeAreaPin)
@Child([.pinVertically, .alignCenterX, .relativeWidth(0.8)])

使用基本配置块

如果您想对子视图本身执行任何配置,或微调自动布局代码,您可以简单地在 @Subview@Child 中使用基本配置块

final class SuccessLabel: Superview {
    @Subview(.pin, {
        $0.text = "Success!"
        $0.font = .systemFont(ofSize: 24, weight: .bold)
        $0.textColor = .systemGreen
    })
    var label = UILabel()
}

使用动态配置块

关于 Subviews 最令人惊奇的事情之一是,它允许您在配置块内使用 self!纯 Swift 泛型,没有魔法。

它适用于两种情况。 首先,它允许您构建复杂的自动布局约束,而仅仅使用快速布局选项是不够的。例如

final class BobaLabel: Superview {
    @Subview([.pinLeading], { (label) in
        label.text = "🧋"
        label.font = .systemFont(ofSize: 48, weight: .heavy)
    })
    var bobaEmoji = UILabel()
    
    // parentheses around (label, self) are required by Swift
    @Subview([.pinTrailing, .pinVertically], { (label, self) in
        label.text = "Boba\nRepublic"
        label.numberOfLines = 0
        label.font = .systemFont(ofSize: 48, weight: .heavy)
        
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: self.bobaEmoji.trailingAnchor),
            label.firstBaselineAnchor.constraint(equalTo: self.bobaEmoji.firstBaselineAnchor)
        ])
    })
    var textLabel = UILabel()
}

其次,它允许您轻松地将自定义类的属性“注入”到子视图中,而无需任何额外的函数

final class ErrorLabel: Superview {
    let error: Error
    
    // parentheses around (label, self) are required by Swift
    @Subview(.pin, { (label, self) in
        label.text = self.error.localizedDescription
        label.textAlignment = .center
        label.numberOfLines = 0
    })
    private var errorLabel = UILabel()
}

如您所见,errorLabel 可以简单地直接从 self 获取 error 属性。 同样,没有魔法!

使用动态创建块

Subview 的另一个非魔法特性是使用带有 self 的动态块创建子视图

final class SymbolLabel: Superview {
    let systemSymbolName: String
    
    // parentheses around (self) are required by Swift
    @Subview(.pin)
    var systemImageView = { (self) in
        let image = UIImage(systemName: self.systemSymbolName)
        return UIImageView(image: image)
    }
}

let volleyball = SymbolLabel(systemSymbolName: "volleyball.fill")

使用堆栈视图

当然,没有堆栈视图就没有现代的 UIKit 代码。 Subviews 使用 @ArrangedSubview(of:) 属性包装器提供对堆栈视图的支持

final class UkraineFlag: Superview {
    // UIStackView should be defined first
    @Subview(.pin, {
        $0.axis = .vertical
        $0.distribution = .fillEqually
    })
    var stack = UIStackView()
    
    // Make sure you use @ArrangedSubview, not @Subview!
    @ArrangedSubview(of: \.stack, {
        $0.backgroundColor = .systemBlue
    })
    var topStripe = UIView()
    
    @ArrangedSubview(of: \.stack, {
        $0.backgroundColor = .systemYellow
    })
    var bottomStripe = UIView()
}

警告
确保您将 @ArrangedSubview 与堆栈视图一起使用,而不是 @Subview

@ArrangedSubview 支持快速布局选项、配置块和动态创建块,与 @Subview 相同

final class FlaggedLogo: Superview {
    let title: String
    
    @Subview(.pin, {
        $0.axis = .horizontal
        $0.alignment = .center
        $0.spacing = 12
    })
    var mainStack = UIStackView()
    
    @ArrangedSubview(of: \.mainStack, [.height(40), .aspectRatioSquare])
    var flag = UkraineFlag()
    
    @ArrangedSubview(of: \.mainStack, { (label, self) in
        label.text = self.title
        label.font = .systemFont(ofSize: 36, weight: .heavy, width: .condensed)
    })
    var text = UILabel()
}

let bravery = FlaggedLogo(title: "BRAVERY")

[实验性] 使用 HorizontalStack / VerticalStack

警告
此功能是实验性的,可能会在未来的版本中更改或删除(会提前通知)。如果您想帮助 Subviews,请尝试一下,我很期待看到您的反馈!

如果您不想使用 @ArrangedSubview,您可以选择实验性的 _HorizontalStack / _VerticalStack 函数,该函数采用 self 指定的结果构建器

final class PeruFlag: Superview {
    let leftStripe = RectangleView(color: .red)
    let centerStripe = RectangleView(color: .white)
    let rightStripe = RectangleView(color: .red)
    
    // parentheses around (self) are required by Swift
    @Subview(.pin, {
        $0.distribution = .fillEqually
    })
    var flagStack = _HorizontalStack { (self) in
        self.leftStripe
        self.centerStripe
        self.rightStripe
    }
}

不要求对将包含在堆栈中的视图使用 @Subview,但是如果您想使用任何 @Subview 提供的功能,例如动态创建块,您可以随意使用

final class TwoColorVerticalFlag: Superview {
    let topColor: UIColor
    let bottomColor: UIColor
    
    @Subview
    var topStripe = { RectangleView(color: $0.topColor) }
    // ^ you can use `$0` instead of `(self)` for shorter code
    
    @Subview
    var bottomStripe = { RectangleView(color: $0.bottomColor) }
    
    @Subview(.pin, {
        $0.distribution = .fillEqually
    })
    var flagStack = _VerticalStack { (self) in
        self.topStripe
        self.bottomStripe
    }
}

let ukraineFlag = TwoColorVerticalFlag(topColor: .systemBlue, bottomColor: .systemYellow)
let polandFlag = TwoColorVerticalFlag(topColor: .white, bottomColor: .red)

或者您可以通过在堆栈块本身内定义子视图来非常接近 SwiftUI

final class TwoColorVerticalFlag: Superview {
    let topColor: UIColor
    let bottomColor: UIColor
    
    @Subview(.pin, {
        $0.distribution = .fillEqually
    })
    var flagStack = _VerticalStack { (self) in
        RectangleView(color: self.topColor)
        RectangleView(color: self.bottomColor)
    }
}

可能性是无限的。 鼓励进行实验。

在不继承 Superview 的情况下使用 @Subview

如果由于任何原因您不想为所有自定义视图采用新的基类 (Superview),您也可以将 @Subview 与自定义 UIView 子类一起使用。 为此,您有两种选择

final class HelloWorld: UIView, AddsSubviews {
    @Subview(.pin, {
        $0.text = "Hello, world"
    })
    var label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // this is the important part:
        self.resolveAllEnclosedProperties()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
final class HelloWorld: UIView, AddsSubviews {
    @Subview(.pin, {
        $0.backgroundColor = .black
    })
    var background = UIView()
    
    @Subview(of: \.background, .alignCenter, {
        $0.text = "Hello, world"
        $0.textColor = .white
    })
    var label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // this is the important part:
        Subviews {
            $background // make sure you're using the `$` prefix
            $label
        }
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

在不继承 ParentViewController 的情况下使用 @Child & @Subview

使用您自己的 UIViewController 子类的方法是相同的

  1. 遵循 AddsChildrenViewControllers 和/或 AddsSubviews
  2. viewDidLoad() 中,在 super.viewDidLoad() 之后和任何自定义代码之前,调用 resolveAllEnclosedProperties()(半自动模式)或 Children / Subviews(手动模式)
// Semi-automatic mode
final class EmptyStateVC: UIViewController, AddsChildrenViewControllers, AddsSubviews {
    
    @Child(.safeAreaPin, {
        $0.view.backgroundColor = .systemGray
    })
    var backgroundVC = UIViewController()
    
    @Subview(.pin)
    var emptyStateView = EmptyStateView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // important part:
        resolveAllEnclosedProperties()
        
        // your code here
    }
}
// Manual mode
final class EmptyStateVC: UIViewController, AddsChildrenViewControllers, AddsSubviews {
    
    @Child(.safeAreaPin, {
        $0.view.backgroundColor = .systemGray
    })
    var backgroundVC = UIViewController()
    
    @Subview(.pin)
    var emptyStateView = EmptyStateView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // important part:
        Children {
            $backgroundVC // don't forget the "$"!
        }
        Subviews {
            $emptyStateView
        }
        
        // your code here
    }
}

已记录的功能

常见问题解答

问:...为什么?

答:我喜欢 UIKit,并且仍然每天都乐于使用它。 以其纯粹的形式,UIKit 使用起来可能相当笨拙,并且通常需要大量的样板代码。 Subviews 最初是一个实验,旨在看看我可以利用 Swift 类型系统到什么程度,才能使 UIKit 使用起来更有趣。 在我的副项目中使用了 Subviews 将近一年之后,我现在无法想象没有它的生活。

问:这东西太荒谬/过度设计/效率低下等等

答:也许吧。 我并没有强迫你使用它。 也许有人会像我一样喜欢使用 Subviews。 其他人可能会发现其代码中使用的一些技术对他们自己的工作有趣且有价值。 任何代码都应该被分享

问:这东西太棒了,我喜欢使用它

答:谢谢! 请随时与我分享您的经验或任何反馈。 如果您对项目的未来方向有任何想法,请联系我(联系方式:@dreymonde

这到底是怎么运作的??

答:主要使用两个很酷的 Swift 功能

  1. 具有封闭实例访问权限的属性包装器
  2. 反射

我正在考虑写一系列文章来解释 Subviews 的内部运作。 敬请关注。

问:反射? 性能如何?

答:是的,显然任何使用反射的东西的性能都会比没有反射的东西差。 但是,在自定义视图子类的大多数用例中,性能下降将是微不足道的。 您可能会惊讶地发现 SwiftUI 大量使用了反射(尽管是更高级的反射),而且似乎并没有困扰任何人。

如果您担心潜在的性能问题,您可以在手动模式下使用 Subviews,请参阅 在不继承 Superview 的情况下使用 @Subview

问:这是否适用于生产环境?

答:我有一些副项目已经在生产环境中运行这段代码一年多了。 所以从我的角度来看,是的,它适用于生产环境。

从技术上讲,它使用了一些带有“下划线前缀”的 Swift API,这些 API 将来可能会更改 - 但它也是 Apple 自己的代码使用的相同 API。 它们不会消失。

也就是说,Subviews 本身尚未处于“1.0”状态。 更新可能会发生重大更改。 它还提供了一些带有下划线前缀的公共 API - 这些是实验性的,可能会更改或删除。

所有与反射相关的代码都是稳定和官方的。 没有什么不正当的行为。

问:我可以混合使用 Subviews 和 UIKit 代码吗?

答:Subviews *就是* 100% UIKit & Auto Layout。 您可以将您自己的 UIKit 代码放在任何地方。

问:这是否适用于 AppKit?

答:尚未。 如果您对此感兴趣,请告诉我。

问:我怎样才能提供帮助?

答:向乌克兰捐款。 谢谢