StylableSwiftUI 5.1.0

StylableSwiftUI 5.1.0

Sam DeanKerr Marin Milleratom.oil 维护。



  • deanWombourne

CI

SwiftUIStylable

基于原子设计原理(https://bradfrost.com/blog/post/atomic-web-design/)尝试通过外部类型使 SwiftUI 组件可定制并跨应用可重用的一尝试。

目标

  • 创建一个库,其中包含可以跨应用重用且无需修改即可定制的 SwiftUI 组件。

  • 组件库可以作为一个 Cocoapod(或 Carthage,或 <gulp> 一个 Swift Package)进行分发

  • 尽可能减少侵入性,尽可能使用样式化 SwiftUI 代码

可选功能

  • 单个样式可以是手动输入或从 Sketch 文件生成

  • 与我们现在工作的格式兼容(部分/元素/标识符),但可以扩展到其他设计系统

  • 在定制定制器中更改样式应更新视图

  • 尽可能可测试

  • 避免全局单例

方法

创建一个 Stylist 对象,并将其作为 environmentObject 传递给主 SwiftUI 对象。

通过传递标识符和修改匹配该标识符的任何视图的方法来配置 Stylist 对象。

添加一个视图修改方法(.style(<identifier>)),这个方法实际上不会使用视图修改器,而是会注入一个包装被修饰视图的Stylist视图(即允许从原子设计的“原子”概念中获取灵感)。

添加一个视图StylistGroup,这将命名所有后续视图的标识符(即允许从原子设计中获取段/元素概念)。

Styled修饰的视图类型将对它包装的视图应用正确的样式,但将保持通用性,这样我们就可以在Stylist中传递它。类型擦除在这里将发挥重要作用,对于代码的一些外观,我感到很抱歉。

将Stylist变为可观察对象,以便对样式列表的更改将触发视图的重绘。

为什么不直接使用ViewModifier?

结果是在视图修饰器内,您无法访问要修改的原始视图,您只能获得some View。如果您想在Text实例上使用任何方法来设置样式,这是一个问题。

我们的设计系统

我们遵循了一种原子设计的变体,选择分为3个级别:章节 / 元素 / 原子。

  • 原子 - 这些将为原生的Swift UI元素提供样式,例如 TextImage

  • 元素 - 这些是我们将基于原子创建的自定义组件,例如 SearchBar

  • 章节 - 这些将是应用程序的章节,例如 clientproduct

这意味着原子的样式可以依据它自身、它所在的元素以及它所在的章节来定义。

我们在代码中创建的组件将在元素级别(SwiftUI已经为我们创建了原子)。将它们放在章节中(或不在,由应用程序自行决定)则由个别应用程序来处理。

匹配标识符

标识符的行为类似于CSS规则。例如,标识符 "title" 不仅可以与 "title" 匹配(这是显然的),还可以与 "section/title""section/element/title" 等匹配。《title》可以被视为 "*/*/title" 来进行匹配。

Stylist 使用此信息来确定应用给视图的样式。例如,如果Stylist对标识符“title”、“section/element/title”和“element/title”有样式,它将为每个传入的视图应用最佳匹配。

已知样式 -> "title" "element/title" section/element/title
Element to match
"title"
"element/title"
"section/element/title"
"othersection/title"

使用方法

创建可共享组件

例如,用于在列表中显示客户的视图。这将与我们的Sketch文件中名为"clientlistitem"的符号相匹配。

struct ClientListItemView: View {

  let client: Client

  var body: some View {
    StyledGroup("clientlistitem") {
      HStack {
        Text(client.name).style("heading")
        Text(client.email).style("body")
        ForEach(client.tags) { tag in
          Text(tag).style("tag")
        }
      }
    }
  }
}

创建样式师

在您的场景代理中,创建根视图并为其提供一个环境对象。

let view = ClientListView()
            .environmentObject(self.stylist)

而且,我们显然还需要创建样式师。

private let stylist: Stylist = {
  let stylist = Stylist()

  // Style for any body text
  stylist.addStyle(identifier: "*/*/body") { 
    $0.font(.body)
  }

  // Style for body text when it's in a clientlist
  stylist.addStyle(identifier: "*/clientlistitem/body") { 
    $0.font(.body).background(Color.red)
  }

  return stylist
}()

SwiftUI 预览

要使视图预览在Xcode中运行,您还需要在其中提供一个样式环境对象。

这是件好事,因为您也可以在那里尝试不同的样式。

struct ClientListItemView_Previews: PreviewProvider {

    /// Some clients to test various layouts
    static private let clients = [
        Client(name: "Max Power", email: "[email protected]", tags: [ "EIP", "Big Spender" ]),
        Client(name: "Mr Smith", email: "[email protected]", tags: [ "Prospect", "EIP" ]),
        Client(name: "Boris Angus Smythe", email: "", tags: []),
    ]

    /// The views to preview
    static var previews: some View {
        ForEach(ClientListItemView_Previews.clients, id: \.self) {
            ClientListItemView(section: "client", client: $0)
        }
        .environmentObject(previewStylist)
        .previewLayout(.fixed(width: 300, height: 70))
    }

    /// The stylist to style the previews with
    static let previewStylist: Stylist = {
        let stylist = Stylist()

        stylist.addStyle(identifier: "body") {
            $0.font(.body)
        }

        stylist.addStyle(identifier: "tag") {
            $0.font(.body).background(Color.red)
        }

        return stylist
    }()
}

不重复样式标识符

具有可样式化视图的问题之一是在添加样式时必须在视图的实现方式和样式师中都输入标识符。这很容易出错,所以这里有几种解决方法:

  1. 为样式标识符创建常量,然后在整个地方使用这些常量。
public struct ClientListItemView: View {
  ...
  public static let headingStyleIdentifier: StylistIdentifier = "clientlistitem/heading"
  ...
}

优点

  • 简单、代码量小

缺点

  • 仍然需要样式师代码知道这些标识符
  1. 创建一个StyleContainer

StyleContainer是一个可以作为一个单独对象应用于样式师的样式集合。您可以使用它来隐藏应用样式的实现细节,例如:

public struct ClientListItemViewStyle: StyleContainer {
    
  public let styles: [Stylist.Style]

  init(headingFont: Font, bodyFont: Font) {
    self.styles = [
      Stylist.Style("clientlistitemview/heading") { $0.font(font) }
      Stylist.Style("clientlistitemview/body") { $0.font(bodyFont) }
      Stylist.Style("clientlistitemview/tag") { $0.font(bodyFont) }
    ]
  }
}

当您向Stylist添加样式时,您就可以使用它,如下所示

stylist.addStyles([
  ClientListItemViewStyle(font: Font("Roboto-Bold", pointSize: 20),
                          bodyFont: Font("Roboto-Regular", pointSize: 14))
])

优点

  • 如果视图的内部实现发生更改,样式容器的外部接口可以保持不变——这对于向后兼容性来说很好。
  • 创建样式时具有更好的类型安全性

缺点

  • 代码量更多

这个是如何工作的?

addStyles(_:) 方法实际上添加的是一个 StyleContainer 数组的集合,而不是单独的 Styles。它能与 Styles 一起工作,因为 Style 符合 StyleContainer 的规范——它是一个只包含一个样式的样式集合。

图片

存在一个名为 StylableImage 的组件,它被赋予一个 StylistIdentifier 而不是一个硬编码的图片路径——然后它通过它在应用中的位置来确定要加载哪个图像资源。它是对 Image 的一个简单替换(技术上,它是在底层包装 Image)。

也就是说:

    StylableGroup("client") {
      ...
      StylableGroup("searchbar") {
        ...
        StylableImage("close")
          .resizable()
          .style("image")
        ...
      }
      ...
    }

在这种情况下,图片会查找名为 "client_searchbar_image""*_%25_searchbar_image""client_*%25_image" 和最终 *_%25_%25_image" 的资源。这允许我们在资源包中放置一个通用的名为 "*_%25_searchbar_image" 的图片,同时也包括一个名为 "client_searchbar_image" 的资源,以便在应用客户端部分时更改图片。

调用 style(_:) 的目的是为了通过样式提供者添加其他样式到图像视图中,这对加载的图像资源没有影响。