Skip to content

Latest commit

 

History

History
643 lines (497 loc) · 26.3 KB

CoreComponentGeneration-Deprecated.md

File metadata and controls

643 lines (497 loc) · 26.3 KB

Component Generation (Deprecated)

This code generation pattern is deprecated. To learn the new pattern, refer to this article.

Not relevant for app developers ("Consumers"). The following information are relevant for SDK maintainers and contributors in order to add new components.

To ensure API consistency and to leverage common implementation logic, we use a component generation pattern when possible. These scripts are located in the sourcery/ directory, and should be executed as follows:

# Generate comonent protocol declarations
sourcery --config .phase_one_sourcery.yml --disableCache
# Generate component APIs, component view body boilerplate, init extensions, model extensions.
sourcery --config .phase_two_sourcery.yml --disableCache
# Generate environment keys/values and view modidiers in view extension.
sourcery --config .phase_three_sourcery.yml --disableCache
# Generate component protocol extensions.
sourcery --config .phase_four_sourcery.yml --disableCache

The output of the generation is at Sources/FioriSwiftUICore/_generated, and should be checked into source control.

  • The phase_one step should produce the "Component" protocol (e.g. TitleComponent) declarations.
  • phase_two should read the set of defined "Models" in order to produce the actual "ViewModel" API. When adding a new view model, developers should copy the generated "Boilerplate" to Sources/FioriSwiftUICore/Views, to implement the actual SwiftUI View body and also provide default style attributes in respective view modifier implementation. This is to prevent the generation process from overwriting the body and style implementation. It also generates conditional initializers and default implementaion for optional properties declared in view model so that developers don't have to provide a value if that property is not needed.
  • phase_three generates the EnvironmentKey, EnvironmentValue and a corresponding view modifier function for each view-representable property declared in view models and component protocols.
  • phase_four generates the default implementation for optional properties declared in component protocols.

By this technique, the developer can introduce and update the properties of a Fiori component, simply by declaring the set of protocols of which its ViewModel is comprised.

Example Component Declaration (composed of primitives)

New Fiori components are added to the SDK by declaring the ViewModel protocol. To introduce a new hypothetical component PersonDetailItem: View, which should have properties title, subtitle, detailImage, a developer will follow this procedure:

In FioriSwiftUICore/Models/ModelDefinitions.swift, declare the protocol PersonDetailItemModel, which aggregates the protocols of its constituent properties. Since we will be using the "sourcery" utility, add the sourcery tag "generated_component".

// sourcery: generated_component
public protocol PersonDetailItemModel: TitleComponent, SubtitleComponent, DetailImage {}

The standard component protocols were generated by the pre phase into Sources/FioriSwiftUICore/_generated/Component+Protocols.generated.swift, and you should compose your view models from these as much as possible. This will maintain API consistency across views.

Additionally, if your component's View body implementation depends upon additional Environment values, such as horizontalSizeClass, use the sourcery tag: // sourcery: add_env_props = "horizontalSizeClass".

The complete declaration will be:

// sourcery: add_env_props = "horizontalSizeClass"
// sourcery: generated_component
public protocol PersonDetailItemModel: TitleComponent, SubtitleComponent, DetailImageComponent {}

If you are only modifying the ModelDefinitions.swift contents, you only need to re-run the sourcery main phase. Execute: sourcery --config sourcery/.phase_main_sourcery.yml.

On success there will be two new files produced:

Sources/FioriSwiftUICore/_generated/ViewModels/API/ProfileDetailItem+API.generated.swift

import SwiftUI

public struct PersonDetailItem<Title: View, Subtitle: View, DetailImage: View> {
    @Environment(\.titleModifier) private var titleModifier
	@Environment(\.subtitleModifier) private var subtitleModifier
	@Environment(\.detailImageModifier) private var detailImageModifier
	@Environment(\.horizontalSizeClass) var horizontalSizeClass

    let _title: Title
	let _subtitle: Subtitle
	let _detailImage: DetailImage
	
    private var isModelInit: Bool = false
	private var isSubtitleNil: Bool = false
	private var isDetailImageNil: Bool = false

    public init(
        @ViewBuilder title: @escaping () -> Title,
		@ViewBuilder subtitle: @escaping () -> Subtitle,
		@ViewBuilder detailImage: @escaping () -> DetailImage
        ) {
            self._title = title()
			self._subtitle = subtitle()
			self._detailImage = detailImage()
    }

    @ViewBuilder var title: some View {
        if isModelInit {
            _title.modifier(titleModifier.concat(Fiori.PersonDetailItem.title).concat(Fiori.PersonDetailItem.titleCumulative))
        } else {
            _title.modifier(titleModifier.concat(Fiori.PersonDetailItem.title))
        }
    }
	@ViewBuilder var subtitle: some View {
        if isModelInit {
            _subtitle.modifier(subtitleModifier.concat(Fiori.PersonDetailItem.subtitle).concat(Fiori.PersonDetailItem.subtitleCumulative))
        } else {
            _subtitle.modifier(subtitleModifier.concat(Fiori.PersonDetailItem.subtitle))
        }
    }
	@ViewBuilder var detailImage: some View {
        if isModelInit {
            _detailImage.modifier(detailImageModifier.concat(Fiori.PersonDetailItem.detailImage).concat(Fiori.PersonDetailItem.detailImageCumulative))
        } else {
            _detailImage.modifier(detailImageModifier.concat(Fiori.PersonDetailItem.detailImage))
        }
    }
    
	var isSubtitleEmptyView: Bool {
        ((isModelInit && isSubtitleNil) || Subtitle.self == EmptyView.self) ? true : false
    }

	var isDetailImageEmptyView: Bool {
        ((isModelInit && isDetailImageNil) || DetailImage.self == EmptyView.self) ? true : false
    }
}

extension PersonDetailItem where Title == Text,
		Subtitle == _ConditionalContent<Text, EmptyView>,
		DetailImage == _ConditionalContent<Image, EmptyView> {

    public init(model: PersonDetailItemModel) {
        self.init(title: model.title_, subtitle: model.subtitle_, detailImage: model.detailImage_)
    }

    public init(title: String, subtitle: String? = nil, detailImage: Image? = nil) {
        self._title = Text(title)
		self._subtitle = subtitle != nil ? ViewBuilder.buildEither(first: Text(subtitle!)) : ViewBuilder.buildEither(second: EmptyView())
		self._detailImage = detailImage != nil ? ViewBuilder.buildEither(first: detailImage!) : ViewBuilder.buildEither(second: EmptyView())

		isModelInit = true
		isSubtitleNil = subtitle == nil ? true : false
		isDetailImageNil = detailImage == nil ? true : false
    }
}

Sources/FioriSwiftUICore/_generated/ViewModels/Boilerplate/ProfileDetailItem+View.generated.swift

//TODO: Copy commented code to new file: `FioriSwiftUICore/Views/PersonDetailItem+View.swift`
//TODO: Implement default Fiori style definitions as `ViewModifier`
//TODO: Implement PersonDetailItem `View` body
//TODO: Implement LibraryContentProvider

/// - Important: to make `@Environment` properties (e.g. `horizontalSizeClass`), internally accessible
/// to extensions, add as sourcery annotation in `FioriSwiftUICore/Models/ModelDefinitions.swift`
/// to declare a wrapped property
/// e.g.:  `// sourcery: add_env_props = ["horizontalSizeClass"]`

/*
import SwiftUI

// FIXME: - Implement Fiori style definitions

extension Fiori {
    enum PersonDetailItem {
        typealias Title = EmptyModifier
        typealias TitleCumulative = EmptyModifier
		typealias Subtitle = EmptyModifier
        typealias SubtitleCumulative = EmptyModifier
		typealias DetailImage = EmptyModifier
        typealias DetailImageCumulative = EmptyModifier

        // TODO: - substitute type-specific ViewModifier for EmptyModifier
        /*
            // replace `typealias Subtitle = EmptyModifier` with:

            struct Subtitle: ViewModifier {
                func body(content: Content) -> some View {
                    content
                        .font(.body)
                        .foregroundColor(.preferredColor(.primary3))
                }
            }
        */
        static let title = Title()
		static let subtitle = Subtitle()
		static let detailImage = DetailImage()
        static let titleCumulative = TitleCumulative()
		static let subtitleCumulative = SubtitleCumulative()
		static let detailImageCumulative = DetailImageCumulative()
    }
}

// FIXME: - Implement PersonDetailItem View body

extension PersonDetailItem: View {
    public var body: some View {
        <# View body #>
    }
}

// FIXME: - Implement PersonDetailItem specific LibraryContentProvider

@available(iOS 14.0, *)
struct PersonDetailItemLibraryContent: LibraryContentProvider {
    @LibraryContentBuilder
    var views: [LibraryItem] {
        LibraryItem(PersonDetailItem(model: LibraryPreviewData.Person.laurelosborn),
                    category: .control)
    }
}
*/

Example Component View Body Implementation

The commented code in ProfileDetailItem+View.generated.swift should be copied & uncommented to Sources/FioriSwiftUICore/Views/ProfileDetailItem+View.swift.

The first task is the body: some View implementation. The developer should never attempt to read directly from the cached closures (e.g. let _title: () -> Title). Instead, the developer should always use the computed variables (e.g. var title: some View), which guarantees that the ViewModifiers will be applied consistently across components--and accounts for empty views.

extension PersonDetailItem: View {
    public var body: some View { 
        HStack {
            detailImage
            VStack {
                title
                subtitle
            }
        }
    }
}

Defining Default Fiori Styling

The default Fiori styling should be declared as a ViewModifier. For each view model, an associated Fiori style enum is declared, with stubs for the ViewModifier which should be applied to each component. To declare the standard style for your component, follow the generated instructions to replace the typealias declarations with a nested struct <ComponentName>: ViewModifier.

extension Fiori {
    enum PersonDetailItem {
        struct Title: ViewModifier {
            func body(content: Content) -> some View {
                    content
                        .font(.headline)
            }
    }
    /* ... */

This style will be applied in the computed variable in ProfileDetailItem+API.generated.swift, as a ViewModifier concatenation.

@ViewBuilder var title: some View {
    if isModelInit {
		_title.modifier(titleModifier.concat(Fiori.PersonDetailItem.title).concat(Fiori.PersonDetailItem.titleCumulative))
    } else {
        _title.modifier(titleModifier.concat(Fiori.PersonDetailItem.title))
    }
}

Note: Component maintainers shall place cumulative styling, e.g. .padding() or .overlay(), in the respective ViewModifiers with suffix Cumulative. Those ViewModifiers will only be applied if the model or content-based initializer are used during runtime. Only non-cumulative styling, e.g. .font() or .lineLimit(), will be applied as Default Fiori Styling. This avoids side effects in case an app developer supplies an own view.

Advanced: suppress EnvironmentKey/Variables and ViewModifer-Style API generation

Use sourcery tag // sourcery: no_style on property of a type conforming to _ComponentGenerating (or _ComponentMultiPropGenerating).

Advanced: Standard component protocols with more than one properties

Define an internal protocol conforming to _ComponentMultiPropGenerating in order to generate a component protocol with more than one properties.

Advanced: Non @ViewBuilder injectable ViewModels

Intended for semantic collection containers which are used as defaul implementations by other ViewModels.

Use sourcery tag // sourcery: generated_component_not_configurable on your ViewModel declaration in FioriSwiftUICore/Models/ModelDefinitions.swift.

Example:

// sourcery: generated_component_not_configurable
public protocol ActivityItemsModel: ActionItemsComponent {}

Advanced: Add availability attribute to a component.

Use sourcery tag // sourcery: availableAttributeContent = on your ViewModel declaration in FioriSwiftUICore/Models/ModelDefinitions.swift.

Example (Declaration of a KPIProgressItemModel that only supports on iOS 14 and later):

// sourcery: availableAttributeContent = "iOS 14, *"
public protocol KPIProgressItemModel: KpiProgressComponent, SubtitleComponent, FootnoteComponent {}

Advanced: ViewModel compositions

Use sourcery tag // sourcery: generated_component_composite to generate ViewModel types which are compositions of other ViewModels.

Example is ContactItemModel which is composed of primitive components (TitleComponent, ...) but also other ViewModels (here: ActivityItemsModel)

To generate a ViewModel (e.g ContactItem) on which a property shall be backed by a SDK control implementation (generated or written manually) you have to declare the following sourcery tag // sourcery: backingComponent = <NameOfBackingView>, unless the property itself is another ViewModel which has the anotation: // sourcery: generated_component_not_configurable.

  • No need to specify backingComponent for a property if it is a ViewModel used for generating a not configurable view.

Because ActivityItemsModel is declared with generated_component_not_configurable annotation, a view component ActivityItems will be generated. We can assume that ActivityItems should implicitly be the backing component of ActivityItemsModel if not stated otherwise. You can override this implicit backing relationship by providing a backingComponent annotation explicitly.

// sourcery: generated_component_not_configurable
public protocol ActivityItemsModel: ActionItemsComponent {}

// sourcery: generated_component_composite
public protocol ContactItemModel: TitleComponent, SubtitleComponent, DescriptionTextComponent, DetailImageComponent {
    var actionItems: ActivityItemsModel? { get }
}

Those changes have the effect that ContactItem will use a default implementation for actionItems in case the app developer used the model or content-based initializers.

extension ContactItem where Title == Text,
		Subtitle == _ConditionalContent<Text, EmptyView>,
		DescriptionText == _ConditionalContent<Text, EmptyView>,
		DetailImage == _ConditionalContent<Image, EmptyView>,
		ActionItems == _ConditionalContent<ActivityItems, EmptyView> {

    public init(model: ContactItemModel) {
        self.init(title: model.title, subtitle: model.subtitle, descriptionText: model.descriptionText, detailImage: model.detailImage, actionItems: model.actionItems != nil ? ActivityItems(model: model.actionItems!) : nil)
    }

    public init(title: String, subtitle: String? = nil, descriptionText: String? = nil, detailImage: Image? = nil, actionItems: ActivityItems? = nil) {
        self._title = Text(title)
		self._subtitle = subtitle != nil ? ViewBuilder.buildEither(first: Text(subtitle!)) : ViewBuilder.buildEither(second: EmptyView())
		self._descriptionText = descriptionText != nil ? ViewBuilder.buildEither(first: Text(descriptionText!)) : ViewBuilder.buildEither(second: EmptyView())
		self._detailImage = detailImage != nil ? ViewBuilder.buildEither(first: detailImage!) : ViewBuilder.buildEither(second: EmptyView())
		self._actionItems = actionItems != nil ? ViewBuilder.buildEither(first: actionItems!) : ViewBuilder.buildEither(second: EmptyView())

		isModelInit = true
		isSubtitleNil = subtitle == nil ? true : false
		isDescriptionTextNil = descriptionText == nil ? true : false
		isDetailImageNil = detailImage == nil ? true : false
		isActionItemsNil = actionItems == nil ? true : false
    }
}
  • backingComponent annotation is needed if the view is written manually.
// sourcery: generated_component_composite
public protocol UserConsentViewModel {
    // sourcery: backingComponent=_UserConsentFormsContainer
    var userConsentForms: [UserConsentFormModel] { get }
 
    ...
}

Sources/FioriSwiftUICore/Views/UserConsentView/_UserConsentFormsContainer.swift

public struct _UserConsentFormsContainer {
    var _userConsentForms: [UserConsentFormModel]

    public init(userConsentForms: [UserConsentFormModel] = []) {
        self._userConsentForms = userConsentForms
    }
}

extension _UserConsentFormsContainer: IndexedViewContainer {
    public var count: Int {
        self._userConsentForms.count
    }
    
    public func view(at index: Int) -> some View {
        UserConsentForm(model: self._userConsentForms[index])
    }
}

Advanced: arbitrary @ViewBuilder properties

If your component's View body implementation shall use an arbitrary view (which is not backed by any SDK component) then you can use the sourcery tag : // sourcery: add_view_builder_params = "<ViewBuilderParameterName>".

The complete declaration will be:

// sourcery: add_view_builder_params = "actionItems"
// sourcery: add_env_props = ["horizontalSizeClass"]
// sourcery: generated_component
public protocol ProfileHeaderModel: TitleComponent, SubtitleComponent, FootnoteComponent, DescriptionTextComponent, DetailImageComponent {}

and the generated ViewModel looks as follows:

public struct ProfileHeader<Title: View, Subtitle: View, Footnote: View, DescriptionText: View, DetailImage: View, ActionItems: View> {
  // ...
}

extension ProfileHeader {
  public init(model: ProfileHeaderModel, @ViewBuilder actionItems: @escaping () -> ActionItems) {
    //...
  }

Supplying the arbitrary view shall be possible for an app developer. Therefore appropriate conditional initializers will be generated in <Component>+Init.generated file

extension ProfileHeader where ActionItems == EmptyView {
    public init(
        @ViewBuilder title: @escaping () -> Title,
		@ViewBuilder subtitle: @escaping () -> Subtitle,
		@ViewBuilder footnote: @escaping () -> Footnote,
		@ViewBuilder descriptionText: @escaping () -> DescriptionText,
		@ViewBuilder detailImage: @escaping () -> DetailImage
    ) {
        self.init(
            title: title,
			subtitle: subtitle,
			footnote: footnote,
			descriptionText: descriptionText,
			detailImage: detailImage,
			actionItems: { EmptyView() }
        )
    }
}

/// and other combinations in which `ActionItems == EmptyView`

Advanced: add property declaration to ViewModel

Use a sourcery annotation for which its key contains virtualProp prefix and its value represents the property declaration (as you would write it manually).

Example:

// sourcery: generated_component
// sourcery: virtualPropIntStateChanged = "var internalStateChanged: Bool = false"
public protocol KeyValueItemModel: KeyComponent, ValueComponent {}

This will add internal stored variable (var internalStateChanged: Bool = false) to KeyValueItem+API.generated.swift and can be used in extensions (written by developers).

Advanced: custom view-returning function builder

Use sourcery annotation // sourcery: customFunctionBuilder=<nameOfExistingCustomFunctionBuilder> to declare the use of a custom view-returning @_functionBuilder (e.g. @IconBuilder) instead of SwiftUI's @ViewBuilder.

internal struct _Component: _ComponentGenerating {
    // sourcery: no_style
    // sourcery: backingComponent=IconStack
    // sourcery: customFunctionBuilder=IconBuilder
    var icons_: [IconStackItem]?
}

The generated ViewModel initializer in <Model>+API.generated.swift as well as the generated conditional initializers in <Model>+Init.generated.swift will then use the custom function builder.

    public init(
		@ViewBuilder detailImage: @escaping () -> DetailImage,
		@IconBuilder icons: @escaping () -> Icons
        ) { 
		  // ...
		}

Advanced: component property not representable as view

Use sourcery annotation // sourcery: no_view on a property which shall not be represented as a view. The property will still be used in the initializers but does not have the @ViewBuilder property wrapper and is declared with its original data type.

internal protocol _KpiProgress: KpiComponent, _ComponentMultiPropGenerating {
    // sourcery: no_view
    var fraction_: Double? { get }
}

Result:

public struct KPIProgressItem<Kpi: View, Subtitle: View, Footnote: View> { // no `Fraction: View` !
    @Environment(\.kpiModifier) private var kpiModifier
	@Environment(\.subtitleModifier) private var subtitleModifier
	@Environment(\.footnoteModifier) private var footnoteModifier

    let _kpi: Kpi
	let _fraction: Double? // data type is used!
	let _subtitle: Subtitle
	let _footnote: Footnote
	
    private var isModelInit: Bool = false
	private var isKpiNil: Bool = false
	private var isSubtitleNil: Bool = false
	private var isFootnoteNil: Bool = false

    public init(
        @ViewBuilder kpi: @escaping () -> Kpi,
		fraction: Double?, // data type is used!
		@ViewBuilder subtitle: @escaping () -> Subtitle,
		@ViewBuilder footnote: @escaping () -> Footnote
        ) {
            self._kpi = kpi()
			self._fraction = fraction // direct assignment
			self._subtitle = subtitle()
			self._footnote = footnote()
    }
    // ...
}

Advanced: component property which is editable

Use Binding to connect the data storage and the view that displays and modifies the data.

Use sourcery annotations bindingProperty and bindingPropertyOptional for converting the primitive type into binding or optional binding properties.

You can provide default value for a optional binding property this way bindingPropertyOptional = .constant(""). This is useful when this property is backed by another component (backingComponent) which has an internal non-optional binding property.

Example

Sources/FioriSwiftUICore/Components/MultiPropertyComponents.swift

internal protocol _TextInput: _ComponentMultiPropGenerating, AnyObject {
    // sourcery: bindingPropertyOptional = .constant("")
    var textInputValue_: String { get set }
    // sourcery: no_view
    var onCommit_: (() -> Void)? { get }
}

Sources/FioriSwiftUICore/_generated/ViewModels/API/TextInput+API.generated.swift

public struct TextInput {
    @Environment(\.textInputValueModifier) private var textInputValueModifier

    var _textInputValue: Binding<String>
	var _onCommit: (() -> Void)? = nil
	
    public init(model: TextInputModel) {
        self.init(textInputValue: Binding<String>(get: { model.textInputValue }, set: { model.textInputValue = $0 }), onCommit: model.onCommit)
    }

    public init(textInputValue: Binding<String>? = nil, onCommit: (() -> Void)? = nil) {
        self._textInputValue = textInputValue ?? .constant("")
		self._onCommit = onCommit
    }
}

Advanced: Provide default value for properties

Use sourcery annotation default.value = <defaultValue> to provide a default value for a component property. If the default value is a string literal, add default.isStringLiteral annotation following the default value. E.g. // sourcery: default.value = "Hello World", default.isStringLiteral

// sourcery: generated_component_composite
public protocol UserConsentFormModel {
    // sourcery: no_view
    // sourcery: default.value = true
    var isRequired: Bool { get }
    
    // sourcery: genericParameter.name = NextActionView
    // sourcery: default.value = _NextActionDefault()
    var nextAction: ActionModel? { get }
}

Sources/FioriSwiftUICore/Models/DefaultViewModels.swift

public struct _NextActionDefault: ActionModel {
    public var actionText: String? {
        NSLocalizedString("Next", comment: "")
    }
    
    public init() {}
}

Advanced: Customize the name and type constraint for the type parameters of a generic view.

Use annotation genericParameter.name and genericParameter.type to customize the name and type constraint respectively for the type paramter related to a property.

It could happen sometimes that the default name of the type parameter may conflict with the backing component name, which causes a compilation error. To workround this we can use genericParameter.name to rename the default type parameter name.

// sourcery: generated_component_composite
public protocol UserConsentPageModel: TitleComponent, BodyAttributedTextComponent {
    // sourcery: genericParameter.name = ActionView
    var action: ActionModel? { get }
}

This is how the generated API looks like:

// The default type parameter name is "Action".
public struct UserConsentPage<..., ActionView: View> {
    let _action: ActionView

    public init(
        ...
		@ViewBuilder action: () -> ActionView
        ) {
			self._action = action()
    }
}

Replace the type constraint View with a custom type using genericParameter.type annotation.

// sourcery: generated_component_composite
public protocol UserConsentViewModel {
    // sourcery: no_style
    // sourcery: backingComponent=_UserConsentFormsContainer
    // sourcery: customFunctionBuilder=IndexedViewBuilder
    // sourcery: genericParameter.type=IndexedViewContainer
    var userConsentForms: [UserConsentFormModel] { get }
}

Generated API:

public struct UserConsentView<UserConsentForms: IndexedViewContainer> {

    let _userConsentForms: UserConsentForms

    public init(
        @IndexedViewBuilder userConsentForms: () -> UserConsentForms,
		...
        ) {
            self._userConsentForms = userConsentForms()
    }
}

Advanced: Insert additional import statement

SwiftUI framework will be imported by default for all the generated components. Use annotation // sourcery: importFrameworks = [<FrameworkName>, ...] to add more frameworks that your component depends on.

// sourcery: importFrameworks = ["Combine"]
// sourcery: generated_component
public protocol PersonDetailItemModel: TitleComponent, SubtitleComponent, DetailImage {}

Next Steps

For now, feel free to prototype with this pattern to add & modify your own controls, and propose enhancements or changes in the Issues tab.

Future Improvements

  • Unify the sourcery generation process for generated_component and generated_component_composite.