一个性能驱动的框架,用于开发 SwiftUI 框架和应用程序。Engine
使创建符合语言习惯的 API 和视图变得更容易,在 SwiftUI 中感觉自然,且不牺牲性能。
选择 File
-> Swift Packages
-> Add Package Dependency
并输入 https://github.com/nathantannar4/Engine
。
您可以将 Engine
添加为 Package.swift
文件中的包依赖项
let package = Package(
//...
dependencies: [
.package(url: "https://github.com/nathantannar4/Engine"),
],
targets: [
.target(
name: "YourPackageTarget",
dependencies: [
.product(name: "Engine", package: "Engine"),
.product(name: "EngineMacros", package: "Engine"), // Optional
],
//...
),
//...
],
//...
)
EngineMacros
包含一个 Swift 宏,需要用户验证才能启用,否则构建将失败。配置 CI 时,传递标志 -skipMacroValidation
给 xcodebuild
以解决此问题。
有关 Engine
入门的一些示例代码,请构建并运行包含的 “Example” 项目。
public macro StyledView()
/// A protocol intended to be used with the ``@StyledView`` macro define a
/// ``ViewStyle`` and all it's related components.
public protocol StyledView: View { }
使用 @StyledView
宏,几乎任何 View
都可以转换为具有 ViewStyle
样式支持的视图。只需将宏附加到任何 StyledView
。
Xcode 的语法高亮目前不适用于宏生成的类型
import EngineMacros
@StyledView
struct LabeledView<Label: View, Content: View>: StyledView {
var label: Label
var content: Content
var body: some View {
HStack {
label
content
}
}
}
extension View {
func labelViewStyle<Style: LabelViewStyle>(_ style: Style) -> some View {
modifier(LabelViewStyleModifier(style))
}
}
struct VerticalLabeledViewStyle: LabeledViewStyle {
func makeBody(configuration: LabeledViewStyleConfiguration) -> some View {
VStack {
configuration.label
configuration.content
}
}
}
struct BorderedLabeledViewStyle: LabeledViewStyle {
func makeBody(configuration: LabeledViewStyleConfiguration) -> some View {
LabeledView(configuration)
.border(Color.red)
}
}
或者,您可以手动实现这些,这对于 ViewStyle
的某些可选功能是必要的。例如,ViewStyledView
可以有一个 body
,如果您希望样式化视图具有每个样式都将应用的根实现(例如,始终添加的 ViewModifier
),则必须实现此 body
。
public protocol ViewStyle {
associatedtype Configuration
associatedtype Body: View
@ViewBuilder
func makeBody(configuration: Configuration) -> Body
}
public protocol ViewStyledView: View {
associatedtype Configuration
var configuration: Configuration { get }
associatedtype DefaultStyle: ViewStyle where DefaultStyle.Configuration == Configuration
static var defaultStyle: DefaultStyle { get }
}
视图样式使开发可重用组件更容易。这对于想要组件具有可自定义外观的框架开发者尤其有用。看看 SwiftUI 本身就知道了。使用 Engine
,您可以通过采用 ViewStyle
协议将相同的功能带到您的应用程序或框架中。与您可能遇到的其他一些样式解决方案不同,ViewStyle
无需依赖 AnyView
即可工作,因此性能非常高。
您可以使用 ViewStyle
API 来制作共享常见行为和/或样式的组件,例如字体/颜色,同时允许完全自定义外观和布局。例如,此 StepperView
组件默认使用 Stepper
,但允许使用不同的自定义样式。
// 1. Define the style
protocol StepperViewStyle: ViewStyle where Configuration == StepperViewStyleConfiguration {
}
// 2. Define the style's configuration
struct StepperViewStyleConfiguration {
struct Label: ViewAlias { } // This lets the `StepperView` type erase the `Label` when used with a `StepperViewStyle`
var label: Label { .init() }
var onIncrement: () -> Void
var onDecrement: () -> Void
}
// 3. Define the default style
struct DefaultStepperViewStyle: StepperViewStyle {
func makeBody(configuration: StepperViewStyleConfiguration) -> some View {
Stepper {
configuration.label
} onIncrement: {
configuration.onIncrement()
} onDecrement: {
configuration.onDecrement()
}
}
}
// 4. Define your custom styles
struct InlineStepperViewStyle: StepperViewStyle {
func makeBody(configuration: StepperViewStyleConfiguration) -> some View {
HStack {
Button {
configuration.onDecrement()
} label: {
Image(systemName: "minus.circle.fill")
}
configuration.label
Button {
configuration.onIncrement()
} label: {
Image(systemName: "plus.circle.fill")
}
}
.accessibilityElement(children: .combine)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
configuration.onIncrement()
case .decrement:
configuration.onDecrement()
default:
break
}
}
}
}
// 5. Add an extension to set the styles
extension View {
func stepperViewStyle<Style: StepperViewStyle>(_ style: Style) -> some View {
styledViewStyle(StepperViewBody.self, style: style)
}
}
// 6. Define the component
struct StepperView<Label: View>: View {
var label: Label
var onIncrement: () -> Void
var onDecrement: () -> Void
init(
@ViewBuilder label: () -> Label,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
) {
self.label = label()
self.onIncrement = onIncrement
self.onDecrement = onDecrement
}
var body: some View {
StepperViewBody(
configuration: .init(
onIncrement: onIncrement,
onDecrement: onDecrement
)
)
.viewAlias(StepperViewStyleConfiguration.Label.self) {
label
}
}
}
extension StepperView where Label == StepperViewStyleConfiguration.Label {
init(_ configuration: StepperViewStyleConfiguration) {
self.label = configuration.label
self.onIncrement = configuration.onIncrement
self.onDecrement = configuration.onDecrement
}
}
// 7. Define the component as a `ViewStyledView`.
struct StepperViewBody: ViewStyledView {
var configuration: StepperViewStyleConfiguration
// Implementing `body` is optional and only neccesary if you would
// like some default styling or modifiers that would be applied
// regardless of the style used
var body: some View {
StepperView(configuration)
// This styling will apply to every `StepperView` regardless of the style used
.padding(4)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary)
)
}
static var defaultStyle: DefaultStepperViewStyle {
DefaultStepperViewStyle()
}
}
// 8. Define a default style based on the `StyleContext` (Optional)
struct AutomaticStepperViewStyle: StepperViewStyle {
func makeBody(configuration: StepperViewStyleConfiguration) -> some View {
StepperView(configuration)
.styledViewStyle(
StepperViewBody.self,
style: InlineStepperViewStyle(),
predicate: .scrollView // Use the inline style when in a ScrollView
)
.styledViewStyle(
StepperViewBody.self,
style: DefaultStepperViewStyle() // If no predicate matches, use the default
)
}
}
public protocol StyleContext {
/// Alternative style contexts that should also be matched against
static var aliases: [StyleContext.Type] { get }
/// Returns if the style context matches
static func evaluate(_ input: StyleContextInputs) -> Bool
}
/// A modifier that applies the `Modifier` only when the`StyleContext` matches
/// the current style context of the view.
@frozen
public struct StyleContextConditionalModifier<
Context: StyleContext,
Modifier: ViewModifier
>: ViewModifier {
@inlinable
public init(predicate: Context, @ViewModifierBuilder modifier: () -> Modifier)
}
/// A modifier that statically applies the `StyleContext`to the view hierarchy.
///
/// See Also:
/// - ``StyleContext``
@frozen
public struct StyleContextModifier<
Context: StyleContext
>: ViewModifier {
@inlinable
public init()
}
StyleContext
可用于有条件地应用 ViewModifier
。这是静态完成的,不使用 AnyView
。
SwiftUI 自动为视图定义 StyleContext
,包括但不限于 ScrollView
和 List
。但您也可以定义自己的 StyleContext
。
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello, World")
.modifier(
StyleContextConditionalModifier(predicate: .none) {
// This modifier will not be applied
BackgroundModifier(color: .red)
}
)
.modifier(
StyleContextConditionalModifier(predicate: .scrollView) {
// This modifier would be applied
BackgroundModifier(color: .blue)
}
)
}
Text("Hello, World")
.modifier(
StyleContextConditionalModifier(predicate: .none) {
// This modifier would be applied
BackgroundModifier(color: .red)
}
)
.modifier(
StyleContextConditionalModifier(predicate: .scrollView) {
// This modifier will not be applied
BackgroundModifier(color: .blue)
}
)
}
}
StyleContext
的一个很好的用例是与自定义视图样式配对使用!
@frozen
public struct AnyShape: Shape {
@inlinable
public init<S: Shape>(shape: S)
}
/// A custom parameter attribute that constructs a `Shape` from closures.
@resultBuilder
public struct ShapeBuilder { }
extension View {
/// Sets a clipping shape for this view.
@inlinable
public func clipShape<S: Shape>(
style: FillStyle = FillStyle(),
@ShapeBuilder shape: () -> S
) -> some View
/// Defines the content shape for hit testing.
@inlinable
public func contentShape<S: Shape>(
eoFill: Bool = false,
@ShapeBuilder shape: () -> S
) -> some View
/// Sets the content shape for this view.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@inlinable
public func contentShape<S: Shape>(
_ kind: ContentShapeKinds,
eoFill: Bool = false,
@ShapeBuilder shape: () -> S
) -> some View
}
向后兼容的 AnyShape
类型擦除。
public protocol ViewAlias: View where Body == Never {
associatedtype DefaultBody: View = EmptyView
@MainActor @ViewBuilder var defaultBody: DefaultBody { get }
}
extension View {
/// Statically type-erases `Source` to be resolved by the ``ViewAlias``.
@inlinable
public func viewAlias<
Alias: ViewAlias,
Source: View
>(
_ : Alias.Type,
@ViewBuilder source: () -> Source
) -> some View
}
ViewAlias
可以由其祖先之一静态定义。由于 ViewAlias
保证是静态的,因此它可以用于类型擦除,而不会产生与 AnyView
相关的性能影响。
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
public protocol ViewOutputKey {
associatedtype Content: View = AnyView
typealias Value = ViewOutputList<Content>
static func reduce(value: inout Value, nextValue: () -> Value)
}
extension View {
/// A modifier that writes a `Source` view to a ``ViewOutputKey``
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
@inlinable
public func viewOutput<
Key: ViewOutputKey,
Source: View
>(
_ : Key.Type,
@ViewBuilder source: () -> Source
) -> some View where Key.Content == Source
ViewOutputKey
允许后代视图向父视图返回一个或多个视图。
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
public protocol ViewOutputAlias: View where Body == Never {
associatedtype Content: View = AnyView
associatedtype DefaultBody: View = EmptyView
@MainActor @ViewBuilder var defaultBody: DefaultBody { get }
}
extension View {
/// Statically defines the `Source` to be resolved by the ``ViewOutputAlias``.
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
@inlinable
public func viewOutputAlias<
Alias: ViewOutputAlias,
Source: View
>(
_ : Alias.Type,
@ViewBuilder source: () -> Source
) -> some View where Alias.Content == Source
}
ViewOutputAlias
是 ViewOutputKey
的更精简变体,仅支持从后代返回单个视图。
@frozen
public struct VariadicViewAdapter<Source: View, Content: View>: View {
@inlinable
public init(
@ViewBuilder source: () -> Source,
@ViewBuilder content: @escaping (VariadicView<Source>) -> Content
)
}
可变参数视图为 SwiftUI 释放了许多可能性,因为它允许将单个视图转换为子视图的集合。要了解更多信息,MovingParts 有一篇关于此主题的精彩博文。
您可以使用 VariadicViewAdapter
来编写自定义选择器视图等组件。
enum Fruit: Hashable, CaseIterable {
case apple
case orange
case banana
}
struct FruitPicker: View {
@State var selection: Fruit = .apple
var body: some View {
PickerView(selection: $selection) {
ForEach(Fruit.allCases, id: \.self) { fruit in
Text(fruit.rawValue)
}
}
.buttonStyle(.plain)
}
}
struct PickerView<Selection: Hashable, Content: View>: View {
@Binding var selection: Selection
@ViewBuilder var content: Content
var body: some View {
VariadicViewAdapter {
content
} content: { source in
ForEachSubview(source) { index, subview in
HStack {
// This works since the ForEach ID is the Fruit (ie Selection) type
let isSelected: Bool = selection == subview.id(as: Selection.self)
if isSelected {
Image(systemName: "checkmark")
}
Button {
selection = subview.id(as: Selection.self)!
} label: {
subview
}
}
}
}
}
}
public protocol VersionedView: View where Body == Never {
associatedtype V5Body: View = V4Body
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
@ViewBuilder var v5Body: V5Body { get }
associatedtype V4Body: View = V3Body
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
@ViewBuilder var v4Body: V4Body { get }
associatedtype V3Body: View = V2Body
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@ViewBuilder var v3Body: V3Body { get }
associatedtype V2Body: View = V1Body
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
@ViewBuilder var v2Body: V2Body { get }
associatedtype V1Body: View = EmptyView
@ViewBuilder var v1Body: V1Body { get }
}
支持 SwiftUI 的多个发布版本可能很棘手。如果修饰符或视图在较新的版本中可用,您可能已经使用了 if #available(...)
。虽然这可行,但性能不高,因为 @ViewBuilder
会将其转换为 AnyView
。此外,代码可能会变得更难阅读。因此,Engine
具有 VersionedView
和 VersionedViewModifier
,用于编写 body
可以根据发布可用性而不同的视图。
您可以使用 VersionedViewModifier
来帮助采用更新的 SwiftUI API,减少摩擦。例如,采用像 Grid
这样的新视图类型,同时仍然支持使用自定义网格视图的旧版 iOS;或者使用新的视图修饰符,由于需要 if #available(...)
检查,这可能会迫使您重构代码。
struct ContentView: VersionedView {
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
var v4Body: some View {
Grid {
// ...
}
}
var v1Body: some View {
CustomGridView {
// ...
}
}
}
struct UnderlineModifier: VersionedViewModifier {
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
func v4Body(content: Content) -> some View {
content.underline()
}
// Add support for a semi-equivalent version for iOS 13-15
func v1Body(content: Content) -> some View {
content
.background(
Rectangle()
.frame(height: 1)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
)
}
}
struct UnderlineButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.modifier(UnderlineIfAvailableModifier()) // #if #available(...) not required
}
}
struct ContentView: View {
var body: some View {
Button {
// ....
} label: {
Text("Underline if #available")
}
.buttonStyle(UnderlineButtonStyle())
}
}
打开 VersionedViewModifier.swift
public protocol StaticCondition {
static var value: Bool { get }
}
@frozen
public struct StaticConditionalContent<
Condition: StaticCondition,
TrueContent: View,
FalseContent: View
>: View {
@inlinable
public init(
_ : Condition.Type = Condition.self,
@ViewBuilder then: () -> TrueContent,
@ViewBuilder otherwise: () -> FalseContent
)
}
@frozen
public struct StaticConditionalModifier<
Condition: StaticCondition,
TrueModifier: ViewModifier,
FalseModifier: ViewModifier
>: ViewModifier {
@inlinable
public init(
_ : Condition.Type = Condition.self,
@ViewModifierBuilder then: () -> TrueModifier,
@ViewModifierBuilder otherwise: () -> FalseModifier
)
}
如果您有修饰符或视图取决于静态标志,Engine
提供了 StaticConditionalContent
和 StaticConditionalModifier
。一个很好的例子是视图或修饰符根据用户界面惯用语而有所不同。当您在 @ViewBuilder
中使用 if/else
时,Swift 编译器不知道条件是静态的。因此,SwiftUI 需要准备好条件发生变化,如果您知道条件是静态的,这可能会不必要地降低性能。
打开 StaticConditionalContent.swift
您可以使用 StaticConditionalContent
将功能或内容限制为 Debug 或 Testflight 构建,而不会影响您的生产构建性能。
struct IsDebug: StaticCondition {
static var value: Bool {
#if DEBUG
return true
#else
return false
#endif
}
}
struct ProfileView: View {
var body: some View {
StaticConditionalContent(IsDebug.self) { // More performant than `if IsDebug.value ...`
NewProfileView()
} otherwise: {
LegacyProfileView()
}
}
}
访问者模式允许从泛型类型 T 转换为具有关联类型的协议,以便可以利用具体类型。
struct ViewAccessor: ViewVisitor {
var input: Any
var output: AnyView {
func project<T>(_ input: T) -> AnyView {
var visitor = Visitor(input: input)
let conformance = ViewProtocolDescriptor.conformance(of: T.self)!
conformance.visit(visitor: &visitor)
return visitor.output
}
return _openExistential(input, do: project)
}
struct Visitor<T>: ViewVisitor {
var input: T
var output: AnyView!
mutating func visit<Content: View>(type: Content.Type) {
let view = unsafeBitCast(input, to: Content.self)
output = AnyView(view)
}
}
}
let value: Any = Text("Hello, World!")
let accessor = ViewAccessor(input: value)
let view = accessor.output
print(view) // AnyView(Text("Hello, World!"))
根据 BSD 2-Clause 许可证分发。有关更多信息,请参阅 LICENSE.md
。