Custom Assets
One of the conscious design decisions we made when building Player was to abstract away the actual asset implementation and open it up for users to bring their own when using Player. This way you can seamlessly integrate Player into your existing experiences and reuse UI assets you may have already built. Below we’ve outlined the way to build custom assets on the various platforms Player supports.
Create Your Asset
First and foremost you need to create a component to handle rendering of your asset. Without any form of transforms, the props to the component will be those from the incoming player content. It’s recommended that you attach the id
, and any other html properties to the root of the asset’s tree:
Assuming your authored JSON has a string property named text, this will render that.
Register it Using a Plugin
Now that we have a React component to render our asset, let’s create a plugin to register with Player:
Typically you register assets by type, but the registry acts by finding the most specific partial object match. This allows you to register more specific implementations for assets of the same type.
Rendering Nested Assets
Often times, assets contain a reference or slot to another asset. For this to function properly, the custom asset needs to defer to the React Player to render the sub-asset. Say for instance we change our custom asset to now support a header
property that takes another asset.
Use the ReactAsset Component from the @player-ui/react
package with the nested asset as props to dynamically determine the rendering implementation to use:
This would automatically find the appropriate handler for the props.header
asset and use that to render.
In order to render an asset a renderer for that type must be registered in the Android Player. If a renderer is found, then Player will delegate rendering when that type is encountered, otherwise Player will skip that node. Creating and registering such a renderer requires the following:
Extending DecodableAsset
DecodableAsset
is a subclass of RenderableAsset
that contains data decoding capabilities built on Kotlinx Serialization. This is the recommended approach for creating an asset and will be consolidated with RenderableAsset
in future versions of the Android Player. On top of the requirements for subclassing RenderableAsset
, subclassing DecodableAsset
requires passing a KSerializer<Data>
for the data class that represents the data for that asset.
RenderableAsset
is the base structure used by Player to convert parsed content into Android Views. Each implementation is instantiated with an AssetContext
and is required to implement two methods, initView
and hydrate
. The separation of logic between these two methods allow for views to be cached and optimize the render process. However, both of these methods are only used internally via the render
method. render
is the main entry point for getting the Android view representation of that asset. It automatically handles the caching and hydration optimizations, only rebuilding and rehydrating when a dependency has changed. The caller would be responsible for handling that view (i.e. injecting it into a ViewGroup).
The RenderableAsset
instance is not guaranteed, meaning that state maintained within a RenderableAsset
may not persist between initView
and hydrate
calls. If state is required, that can be accomplished by creating a custom View
.
Some asset implementations may encounter a situation where the cached view is no longer the corresponding representation of the asset. Under this circumstance, the asset can request a full re-render by calling invalidateView
from any point in the hydration context.
Implementing initView
The only goal of initView
is to build an Android View. This can be done through inflation, programmatic building, or some framework, as long as the View that is returned represents the corresponding asset. Top-level view creation and any one-time configuration operations should be done in this step. It is best practice to ensure that any access of the asset model is not done in this phase, as initView
is not guaranteed to be called if the data changes.
Implementing hydrate
Hydration is the process responsible for populating the view with the data from the asset model. Any dependencies on the data model should be handled in this step. This includes accessing data, transform functions, or even nested assets. Any views created in hydrate
will not be automatically cached, but will persist on the UI unless explicitly removed. It is necessary to be vigilant when constructing and removing these views.
Accessing Data
In most cases, there is some additional data that is used to make the rendering more meaningful. For instance, the intent of the previous text
asset example was to render a View that displayed the string contained in value
. Access to such data will be provided through a data
member on the DecodableAsset
. This data
member is a type specified when defining the subclass.
With this defined, the data
can be accessed as an instance of TextAsset.Data
. It’s important to note that if value
isn’t defined in the content, this will cause a crash because there isn’t a default value provided. If you have optional fields, make sure the data class is structured appropriately:
As a fallback, data can still be accessed via the Asset instance attached to the AssetContext. The Asset instance is a link into the underlying asset node, which provides a set of getter methods to retrieve data.
Nested assets
Compound assets can be defined such that the asset model contains child assets. These child assets must be wrapped in an asset object. In the example above, there is an card
asset that delegates to a text
asset to render a title. The child asset can be directly described as a RenderableAsset
in the data class.
A helper is provided to reduce overhead with rendering an asset into a layout. into
will show or hide the target ViewGroup
based on whether the View
is null.
Styling
Styling can be done completely independently, but the API is designed such that render
can accept any number of style resources. This allows parent assets to declare any styles that they’d like the child asset to use. These styles are automatically overlaid onto the current Android Context.
In the above card
asset example, the card
may want to set some text styles to make any text that’s rendered look like a title (i.e. bold, larger, etc).
SwiftUI Player assets are made of 3 parts:
- Data: Decodable AssetData
- View: A SwiftUI View
- Asset: SwiftUIAsset implementation to tie the two together
Registering Your Asset: Additional Topic
Data
SwiftUI Player relies on assets decoding data that conforms to AssetData
, this is necessary, because id
and type
are needed to determine what registered Swift type to decode to. You can include any decodable types in this struct as needed to match the structure of the asset that is returned from the core player.
Beyond this, there are a few wrapper types that handle some player specific features:
ModelReference
ModelReference
is a wrapper that gets the raw JSValue from Player for a specific node in your asset data. This wrapper exists because if a reference to the data model is used in content, such as:
While count
will not necessarily be a string, in the underlying JavaScript layer, if the entire string value is just a reference to the data model, it is replaced with the exact value from the data model. This means that if count
is a number in the data model, when you receive it in the swift layer it will be an Int
. So ModelReference
gives you a quick helper to get it as a string:
You can also access someModelReference.rawValue
if you need to access the underlying JSValue for some other casting.
WrappedFunction
JavaScript plugins loaded into the core player, when you have a plugin that extends JSBasePlugin, can transform the resolved asset before it reaches the Swift layer. In many situations, this results in functions being added to an asset to ensure that the same functionality is used on all platforms, and reduce code duplication. WrappedFunction
gives you a light wrapper to help decode and call those functions. It takes generic parameter that defines the return type of the function:
WrappedAsset
Last but not least, WrappedAsset
represents another asset being defined as a part of this asset. This will be a very common pattern as Player content is intended to be semantic and dynamic. Therefore we need to know that there is an asset in our data, but not what it is, as the implementation is not guaranteed.
Rendering these nested assets will be described below.
View
The view for a SwiftUI Asset is a regular SwiftUI View
. Any standard SwiftUI components and concepts will work as normal. The only differentiating factor when it comes to Player assets, is the WrappedAsset
and rendering it. WrappedAsset
contains a SwiftUIAsset?
, so in the event it was not decodable, it will be nil
, otherwise, you can access it’s view
property to get a type-erased AnyView
and render it in your view:
Asset
The SwiftUIAsset
is the glue between the View
and the Data
. Player will handle decoding data, and updating the data in an ObservableObject
viewModel that contains the Data
you tell it to decode.
UncontrolledAsset
The UncontrolledAsset
is uncontrolled because you do not specify a viewModel
type, and receive an implicit AssetViewModel<T: AssetData>
.
ControlledAsset
The ControlledAsset
lets you define the viewModel type, as long as it subclasses AssetViewModel<T: AssetData>
, this way you still receive updated data and user info whenever Player changes state, but you can add other functionality to the viewModel
.
Linking the View
In either situation, your asset implementation needs only to override the view property and return the type erased view you want to use.
Additional Topics
Interacting with the Data Model without a transform
If data needs to be set or retrieved without the use of a transform, the InProgressState
is available in an environment object, where the DataController
can be accessed, as well as other utilities:
If your experience will be used on multiple platforms, it is not advised to use this method, a transform will ensure the same logic is followed on all 3 platforms and is strongly encouraged.
Registering your Asset
When registering your asset with an AssetRegistry
, it can either be registered as a new type, if it is an entirely new construct, or registered as a variant of an existing asset type, to only be rendered under certain conditions.
Registering assets is done in the AndroidPlayerPlugin
. Each plugin only needs to implement an apply
method which gives the plugin the opportunity to supplement core player functionality. The AndroidPlayer
instance contains an asset registry where assets should be register. A helper method has been created to make registration as simple as providing the type and a factory. The factory method must take an AssetContext, and is recommended to just be the constructor of your asset.
In the latter case, it is recommended to extend the original asset, so as to avoid boilerplate for data and construction, and just override the render function. If your variant will have additional data decoded that the original asset does not have, you will need to create the whole asset.
Why Would I Register my Asset as a Variant?
-
Transform backed assets have functions that are attached to them, through shared JavaScript plugins. This simplifies setting data from the asset, by giving simple functions like
run
in the referenceActionAsset
for example. Swift only asset types will not have any convenience functions. -
Registering as a variant allows you to maintain usage of the transform backed asset as well as your new asset, so both can be used by the same
SwiftUIPlayer
orAndroidPlayer
instance, including in the same flow. This also maintains the semantics of Player content, anaction
asset is always anaction
type of interaction, but withmetaData
, it can be displayed differently.
For more info on transform registration see Asset Transform Plugin
Use Cases
Below are 3 different use cases for different ways to mix and match the asset and transform registry to simplify the asset implementation
Use Case 1: Same type with different variants and asset implementations that can share the same transform
If the common InputData fields for the decoded data looks like:
Taken from the reference asset Input Asset example, see full transform implementation
The InputData used in Swift and Android is using the TransformedInput which includes the original web input properties plus additional transform specific properties
The InputData used in Swift and Android is using the TransformedInput which includes the original web input properties plus additional transform specific properties
And we would like to render two different assets based on whether or not “dataType” is present then both InputAsset and DateInputAsset can share the same InputData which can contain a transform (such as the function to perform after input data is set) but show different content for the views such as input accessories like a calender based on the DataType
Use Case 2: Same type with different variants and asset implementations that don’t share the same transform
If the common InputData fields for the decoded data looks like:
In the case where the regular InputAsset and the DateInputAsset should not share the same transform, its possible to target the variant in the transform registration (since transform also use the partial match registry) to specify a different transform when the “dataType” is present for example:
The InputData used in Swift and Android is using the TransformedInput which includes the original web input properties plus additional transform specific properties
The InputData used in Swift and Android is using the TransformedInput which includes the original web input properties plus additional transform specific properties
Use Case 3: Different type, same asset implementation, different transforms
Its possible to register the same asset implementation to different type names with the same variant, this may be needed if the two types visually look the same but behaviourally is different such as when the choice is clicked “choiceA” does one action but “choiceB” does something else which is defined in the transform
Since the transform is called on “select” of the WrappedFunction
or Invokable<Unit>
in the data this doesnt change the ChoiceData (only the values of select function itself change depending on if we get ChoiceA or ChoiceB) which means they can both be registered to ChoiceAsset
Create two transforms choiceATransform and choiceBTransform that both return TransformedChoice but have different functions on select. Then in the web transform registration choiceA and choiceB are registered to those different transforms
The ChoiceData used in Swift and Android is using the TransformedChoice which includes the original web choice properties plus additional transform specific properties
The ChoiceData used in Swift and Android is using the TransformedChoice which includes the original web choice properties plus additional transform specific properties
Overall the asset and transform registry gives developers a lot of flexibility for extending and simplifying assets based on given constraints