引擎

一个性能驱动的框架,用于开发 SwiftUI 框架和应用程序。Engine 使创建符合语言习惯的 API 和视图变得更容易,在 SwiftUI 中感觉自然,且不牺牲性能。

另请参阅

要求

安装

Xcode 项目

选择 File -> Swift Packages -> Add Package Dependency 并输入 https://github.com/nathantannar4/Engine

Swift Package Manager 项目

您可以将 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
            ],
            //...
        ),
        //...
    ],
    //...
)

Xcode Cloud / Github Actions / Fastlane / CI

EngineMacros 包含一个 Swift 宏,需要用户验证才能启用,否则构建将失败。配置 CI 时,传递标志 -skipMacroValidationxcodebuild 以解决此问题。

Engine 简介

有关 Engine 入门的一些示例代码,请构建并运行包含的 “Example” 项目。

使用 @StyledView 的自定义视图样式

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 的语法高亮目前不适用于宏生成的类型

打开 StyledView.swift

示例

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 的自定义视图样式

或者,您可以手动实现这些,这对于 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.swift

示例

您可以使用 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
            )
    }
}

打开 Examples

样式上下文

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,包括但不限于 ScrollViewList。但您也可以定义自己的 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 的一个很好的用例是与自定义视图样式配对使用!

打开 StyleContext.swift

形状

@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 相关的性能影响。

打开 ViewAlias.swift

视图输出

@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 允许后代视图向父视图返回一个或多个视图。

打开 ViewOutputKey.swift

@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
}

ViewOutputAliasViewOutputKey 的更精简变体,仅支持从后代返回单个视图。

打开 ViewOutputAlias.swift

可变参数视图

@frozen
public struct VariadicViewAdapter<Source: View, Content: View>: View {

    @inlinable
    public init(
        @ViewBuilder source: () -> Source,
        @ViewBuilder content: @escaping (VariadicView<Source>) -> Content 
    )
}

可变参数视图为 SwiftUI 释放了许多可能性,因为它允许将单个视图转换为子视图的集合。要了解更多信息,MovingParts 有一篇关于此主题的精彩博文。

打开 VariadicView.swift

示例

您可以使用 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
                    }
                }
            }
        }
    }
}

打开 Examples

可用性

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 具有 VersionedViewVersionedViewModifier,用于编写 body 可以根据发布可用性而不同的视图。

打开 VersionedView.swift

示例

您可以使用 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 提供了 StaticConditionalContentStaticConditionalModifier。一个很好的例子是视图或修饰符根据用户界面惯用语而有所不同。当您在 @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()
        }
    }
}

打开 更多示例

EngineCore

访问者模式允许从泛型类型 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