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 component 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" toSources/FioriSwiftUICore/Views
, to implement the actual SwiftUIView
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 implementation 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 theEnvironmentKey
,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.
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)
}
}
*/
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 ViewModifier
s will be applied consistently across components--and accounts for empty views.
extension PersonDetailItem: View {
public var body: some View {
HStack {
detailImage
VStack {
title
subtitle
}
}
}
}
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.
Use sourcery tag // sourcery: no_style
on property of a type conforming to _ComponentGenerating
(or _ComponentMultiPropGenerating
).
Define an internal protocol conforming to _ComponentMultiPropGenerating
in order to generate a component protocol with more than one properties.
Intended for semantic collection containers which are used as default 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 {}
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 {}
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 annotation: // 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])
}
}
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`
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).
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
) {
// ...
}
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()
}
// ...
}
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
}
}
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() {}
}
Use annotation genericParameter.name
and genericParameter.type
to customize the name and type constraint respectively for the type parameter 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 workaround 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()
}
}
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 {}
For now, feel free to prototype with this pattern to add & modify your own controls, and propose enhancements or changes in the Issues tab.
- Unify the sourcery generation process for
generated_component
andgenerated_component_composite
.