使用用声明式语法用 UIStackView —— @resultBuilder,functionBuilder

610 阅读1分钟

起源是在 github 上看到 keanStacks (A micro UIStackView convenience API inspired by SwiftUI)

使用 Stacks 可以用声明式语法创建 UIStackView

let stack: UIView = .hStack(alignment: .center, margins: .all(16), [
    .vStack(spacing: 8, [
        titleLabel,
        subtitleLabel
    ]),
    .spacer(minLength: 16),
    star
])

但是 Swift 有 resultBulider(functionBuilder),使用它可以更像 SwiftUI 例如这样

let content = VStack {
    VStack {
      iconImageView.width(.point(136)).height(.point(136))
      specLabel
    }
    .spacing(10)
    .alignment(.center)
    VStack {
      facebookLoginButton.height(.point(48))
      googleLoginButton.height(.point(48))
      mobileLoginButton.height(.point(48))
      if #available(iOS 13.0, *) {
          appleLoginButton.height(.point(48))
      }
    }
    .spacing(18)
}
.distribution(.equalSpacing)

在 stacks 的基础上用 resultbuilder 改造

swift 官方文档

A result builder is a type you define that adds syntax for creating nested data, like a list or tree, in a natural, declarative way.

@resultBuilder 
struct SettingsBuilder { 
  static func buildBlock() -> [Setting] { [] } 
}

其他

Stacks 源码上有个问题, updateConstraints 的调用位置错了

fileprivate override func updateConstraints() {
  super.updateConstraints()

  NSLayoutConstraint.deactivate(_constraints)

  let attributes: [NSLayoutConstraint.Attribute]
  switch axis {
  case .horizontal: attributes = [.width]
  case .vertical: attributes = [.height]
  default: attributes = [.height, .width] // Not really an expected use-case
  }
  _constraints = attributes.map {
    let constraint = NSLayoutConstraint(item: self, attribute: $0, relatedBy: isFixed ? .equal : .greaterThanOrEqual, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: length)
    constraint.priority = UILayoutPriority(999)
    return constraint
  }

  NSLayoutConstraint.activate(_constraints)
}

Important: Call [super updateConstraints] as the final step in your implementation.

使用 resultBuilder(functionBuilder) 封装的过程


protocol StackNode {
  var subNodes: [UIView] { get }
}

extension UIView: StackNode {
  var subNodes: [UIView] { [self] }
}

struct ComponentNode: StackNode {
  let views: [UIView]
  var subNodes: [UIView] { views }
}

struct EmptyNode: StackNode {
  var subNodes: [UIView] { [] }
}

@resultBuilder
struct StackBuilder {
  static func buildBlock(_ component: StackNode) -> StackNode {
    return component
  }
  
  static func buildBlock(_ components: StackNode...) -> StackNode {
    ComponentNode(views: components.flatMap{ $0.subNodes })
  }
  
  static func buildBlock(_ components: [StackNode]) -> StackNode {
    ComponentNode(views: components.flatMap{ $0.subNodes })
  }
  
  static func buildArray(_ components: [StackNode]) -> StackNode {
    ComponentNode(views: components.flatMap{ $0.subNodes })
  }
  
  static func buildEither(first component: StackNode) -> StackNode {
    component
  }
  
  static func buildEither(second component: StackNode) -> StackNode {
    component
  }
  
  static func buildOptional(_ component: StackNode?) -> StackNode {
    component ?? EmptyNode()
  }
}

final class VStack: UIStackView {
  init(@StackBuilder nodes: () -> StackNode) {
    super.init(frame: .zero)
    axis = .vertical
    translatesAutoresizingMaskIntoConstraints = false
    nodes().subNodes.forEach { addArrangedSubview($0) }
  }
  required init(coder: NSCoder) { super.init(coder: coder) }
}

protocol StackModifier {
  associatedtype Stack: UIStackView
  func alignment(_ alignment: Stack.Alignment) -> Stack
  func distribution(_ distribution: Stack.Distribution) -> Stack
  func spacing(_ spacing: CGFloat) -> Stack
}

extension VStack: StackModifier {
  func alignment(_ alignment: UIStackView.Alignment) -> VStack {
    self.alignment = alignment
    return self
  }
  
  func distribution(_ distribution: UIStackView.Distribution) -> VStack {
    self.distribution = distribution
    return self
  }
  
  func spacing(_ spacing: CGFloat) -> VStack {
    self.spacing = spacing
    return self
  }
}

final class HStack: UIStackView {
  init(@StackBuilder nodes: () -> StackNode) {
    super.init(frame: .zero)
    axis = .horizontal
    translatesAutoresizingMaskIntoConstraints = false
    nodes().subNodes.forEach { addArrangedSubview($0) }
  }
  required init(coder: NSCoder) { super.init(coder: coder) }
}

extension HStack: StackModifier {
  func alignment(_ alignment: UIStackView.Alignment) -> HStack {
    self.alignment = alignment
    return self
  }
  
  func distribution(_ distribution: UIStackView.Distribution) -> HStack {
    self.distribution = distribution
    return self
  }
  
  func spacing(_ spacing: CGFloat) -> HStack {
    self.spacing = spacing
    return self
  }
}

protocol ViewModifier {
  associatedtype View: UIView
  func width(_ width: SpacerValue) -> View
  func height(_ height: SpacerValue) -> View
  func cornerRadius(_ cornerRadius: CGFloat) -> View
  func backgroundColor(_ backgroundColor: UIColor) -> View
}

extension UIView: ViewModifier {
  
  @discardableResult
  public func width(_ width: SpacerValue) -> UIView {
    if let w = constraints.first(where: { $0.firstAnchor == widthAnchor }) {
      w.isActive = false
    }
    translatesAutoresizingMaskIntoConstraints = false
    let constraint: NSLayoutConstraint
    switch width {
      case let .fraction(value, relation):
        constraint = NSLayoutConstraint(item: self, attribute: .width, relatedBy: relation, toItem: superview, attribute: .width, multiplier: value, constant: 0)
      case let .point(value, relation):
        constraint = NSLayoutConstraint(item: self, attribute: .width, relatedBy: relation, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: value)
    }
    constraint.priority = UILayoutPriority(999)
    constraint.isActive = true
    return self
  }
  
  @discardableResult
  public func height(_ height: SpacerValue) -> UIView {
    if let h = constraints.first(where: { $0.firstAnchor == heightAnchor }) {
      h.isActive = false
    }
    let constraint: NSLayoutConstraint
    switch height {
      case let .fraction(value, relation):
        constraint = NSLayoutConstraint(item: self, attribute: .height, relatedBy: relation, toItem: superview, attribute: .height, multiplier: value, constant: 0)
      case let .point(value, relation):
        constraint = NSLayoutConstraint(item: self, attribute: .height, relatedBy: relation, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: value)
    }
    constraint.priority = UILayoutPriority(999)
    constraint.isActive = true
    return self
  }
  
  @discardableResult
  public func cornerRadius(_ cornerRadius: CGFloat) -> UIView {
    layer.cornerRadius = cornerRadius
    return self
  }
  
  @discardableResult
  public func backgroundColor(_ color: UIColor) -> UIView {
    backgroundColor = color
    return self
  }
  
  @discardableResult
  public func contentCompressionResistancePriority(_ priority: UILayoutPriority, axis: NSLayoutConstraint.Axis) -> UIView {
    translatesAutoresizingMaskIntoConstraints = false
    setContentCompressionResistancePriority(priority, for: axis)
    return self
  }
  
  @discardableResult
  public func contentHuggingPriority(_ priority: UILayoutPriority, axis: NSLayoutConstraint.Axis) -> UIView {
    translatesAutoresizingMaskIntoConstraints = false
    setContentHuggingPriority(priority, for: axis)
    return self
  }
}