Advanced implementation guide (optional)
This optional and advanced implementation guide covers Content Card code considerations, three custom use cases built by our team, accompanying code snippets, and guidance on logging impressions, clicks, and dismissals. Visit our Braze Demo Repository here! Note that this implementation guide is centered around a Kotlin implementation, but Java snippets are provided for those interested.
Looking for the basic Content Card developer integration guide? Find it here.
More information on customizing Content Cards can be found in the Customization Guide.
Code considerations
Import statements and helper files
When building out Content Cards, you should expose the Braze SDK via a single manager singleton. This pattern shields your application code from the Braze implementation details behind a shared abstraction that makes sense for your use case. It also makes it easier to track, debug, and alter code. An example manager implementation can be found here.
Content Cards as custom objects
Your own custom objects already in use in your application can be extended to carry Content Card data, thereby abstracting the source of the data into a format already understood by your application code. Data source abstractions provide flexibility to work with different data backends interchangeably and in concert. In this example, we’ve defined the ContentCardable
abstract base class to represent both our existing data (fed, in this example, from a local JSON file) and the new data fed from the Braze SDK. The base class also exposes the raw Content Card data for consumers that need to access the original Card
implementation.
When initializing ContentCardable
instances from the Braze SDK, we use the class_type
extra to map the Content Card to a concrete subclass. We then use the additional key-value pairs set within the Braze dashboard to populate the necessary fields.
Once you have a solid understanding of these code considerations, check out our use cases to start implementing your own custom objects.
No Card
dependencies
ContentCardData
represents the parsed out, common values of an Card
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
abstract class ContentCardable (){
var cardData: ContentCardData? = null
constructor(data:Map<String, Any>):this(){
cardData = ContentCardData(data[idString] as String,
ContentCardClass.valueFrom(data[classType] as String),
data[created] as Long,
data[dismissable] as Boolean)
}
val isContentCard: Boolean
get() = cardData != null
fun logContentCardClicked() {
BrazeManager.getInstance().logContentCardClicked(cardData?.contentCardId)
}
fun logContentCardDismissed() {
BrazeManager.getInstance().logContentCardDismissed(cardData?.contentCardId)
}
fun logContentCardImpression() {
BrazeManager.getInstance().logContentCardImpression(cardData?.contentCardId)
}
}
data class ContentCardData (var contentCardId: String,
var contentCardClassType: ContentCardClass,
var createdAt: Long,
var dismissable: Boolean)
No Card
dependencies
ContentCardData
represents the parsed out, common values of an Card
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public abstract class ContentCardable{
private ContentCardData cardData = null;
public ContentCardable(Map<String, Object> data){
cardData = new ContentCardData()
cardData.contentCardId = (String) data.get(idString);
cardData.contentCardClassType = contentCardClassType.valueOf((String)data.get(classType));
cardData.createdAt = Long.parseLong((String)data.get(createdAt));
cardData.dismissable = Boolean.parseBoolean((String)data.get(dismissable));
}
public ContentCardable(){
}
public boolean isContentCard(){
return cardData != null;
}
public void logContentCardClicked() {
if (isContentCard()){
BrazeManager.getInstance().logContentCardClicked(cardData.contentCardId)
}
}
public void logContentCardDismissed() {
if(isContentCard()){
BrazeManager.getInstance().logContentCardDismissed(cardData.contentCardId)
}
}
public void logContentCardImpression() {
if(isContentCard()){
BrazeManager.getInstance().logContentCardImpression(cardData.contentCardId)
}
}
}
public class ContentCardData{
public String contentCardId;
public ContentCardClass contentCardClassType;
public long createdAt;
public boolean dismissable;
}
Custom object initializer
Metadata from a Card
is used to populate your concrete subclass variables. Depending on the subclass, you may need to extract different values during initialization. The key-value pairs set up in the Braze dashboard are represented in the “extras” dictionary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Tile: ContentCardable {
constructor(metadata:Map<String, Any>):super(metadata){
val extras = metadata[extras] as? Map<String, Any>
title = extras?.get(Keys.title) as? String
image = extras?.get(Keys.image) as? String
detail = metadata[ContentCardable.detail] as? String
tags = (metadata[ContentCardable.tags] as? String)?.split(",")
val priceString = extras?.get(Keys.price) as? String
if (priceString?.isNotEmpty() == true){
price = priceString.toDouble()
}
id = floor(Math.random()*1000).toInt()
}
}
Custom object initializer
Metadata from a Card
is used to populate your concrete subclass variables. Depending on the subclass, you may need to extract different values during initialization. The key-value pairs set up in the Braze dashboard are represented in the “extras” dictionary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Tile extends ContentCardable {
public Tile(Map<String, Object> metadata){
super(metadata);
this.detail = (String) metadata.get(ContentCardable.detail);
this.tags = ((String)metadata.get(ContentCardable.tags)).split(",");
if (metadata.containsKey(Keys.extras)){
Map<String, Object> extras = metadata.get(Keys.extras);
this.title = (String)extras.get(Keys.title);
this.price = Double.parseDouble((String)extras.get(Keys.price));
this.image = (String)extras.get(Keys.image);
}
}
}
Custom card rendering
The following lists information on how to change how any card is rendered in the recyclerView
. The IContentCardsViewBindingHandler
interface defines how all Content Cards get rendered. You can customize this to change anything you want:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public class DefaultContentCardsViewBindingHandler implements IContentCardsViewBindingHandler {
// Interface that must be implemented and provided as a public CREATOR
// field that generates instances of your Parcelable class from a Parcel.
public static final Parcelable.Creator<DefaultContentCardsViewBindingHandler> CREATOR = new Parcelable.Creator<DefaultContentCardsViewBindingHandler>() {
public DefaultContentCardsViewBindingHandler createFromParcel(Parcel in) {
return new DefaultContentCardsViewBindingHandler();
}
public DefaultContentCardsViewBindingHandler[] newArray(int size) {
return new DefaultContentCardsViewBindingHandler[size];
}
};
/**
* A cache for the views used in binding the items in the {@link android.support.v7.widget.RecyclerView}.
*/
private final Map<CardType, BaseContentCardView> mContentCardViewCache = new HashMap<CardType, BaseContentCardView>();
@Override
public ContentCardViewHolder onCreateViewHolder(Context context, List<? extends Card> cards, ViewGroup viewGroup, int viewType) {
CardType cardType = CardType.fromValue(viewType);
return getContentCardsViewFromCache(context, cardType).createViewHolder(viewGroup);
}
@Override
public void onBindViewHolder(Context context, List<? extends Card> cards, ContentCardViewHolder viewHolder, int adapterPosition) {
Card cardAtPosition = cards.get(adapterPosition);
BaseContentCardView contentCardView = getContentCardsViewFromCache(context, cardAtPosition.getCardType());
contentCardView.bindViewHolder(viewHolder, cardAtPosition);
}
@Override
public int getItemViewType(Context context, List<? extends Card> cards, int adapterPosition) {
Card card = cards.get(adapterPosition);
return card.getCardType().getValue();
}
/**
* Gets a cached instance of a {@link BaseContentCardView} for view creation/binding for a given {@link CardType}.
* If the {@link CardType} is not found in the cache, then a view binding implementation for that {@link CardType}
* is created and added to the cache.
*/
@VisibleForTesting
BaseContentCardView getContentCardsViewFromCache(Context context, CardType cardType) {
if (!mContentCardViewCache.containsKey(cardType)) {
// Create the view here
BaseContentCardView contentCardView;
switch (cardType) {
case BANNER:
contentCardView = new BannerImageContentCardView(context);
break;
case CAPTIONED_IMAGE:
contentCardView = new CaptionedImageContentCardView(context);
break;
case SHORT_NEWS:
contentCardView = new ShortNewsContentCardView(context);
break;
case TEXT_ANNOUNCEMENT:
contentCardView = new TextAnnouncementContentCardView(context);
break;
default:
contentCardView = new DefaultContentCardView(context);
break;
}
mContentCardViewCache.put(cardType, contentCardView);
}
return mContentCardViewCache.get(cardType);
}
// Parcelable interface method
@Override
public int describeContents() {
return 0;
}
// Parcelable interface method
@Override
public void writeToParcel(Parcel dest, int flags) {
// Retaining views across a transition could lead to a
// resource leak so the parcel is left unmodified
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class DefaultContentCardsViewBindingHandler : IContentCardsViewBindingHandler {
// Interface that must be implemented and provided as a public CREATOR
// field that generates instances of your Parcelable class from a Parcel.
val CREATOR: Parcelable.Creator<DefaultContentCardsViewBindingHandler?> = object : Parcelable.Creator<DefaultContentCardsViewBindingHandler?> {
override fun createFromParcel(`in`: Parcel): DefaultContentCardsViewBindingHandler? {
return DefaultContentCardsViewBindingHandler()
}
override fun newArray(size: Int): Array<DefaultContentCardsViewBindingHandler?> {
return arrayOfNulls(size)
}
}
/**
* A cache for the views used in binding the items in the [RecyclerView].
*/
private val mContentCardViewCache: MutableMap<CardType, BaseContentCardView<*>?> = HashMap()
override fun onCreateViewHolder(context: Context?, cards: List<Card?>?, viewGroup: ViewGroup?, viewType: Int): ContentCardViewHolder? {
val cardType = CardType.fromValue(viewType)
return getContentCardsViewFromCache(context, cardType)!!.createViewHolder(viewGroup)
}
override fun onBindViewHolder(context: Context?, cards: List<Card>, viewHolder: ContentCardViewHolder?, adapterPosition: Int) {
if (adapterPosition < 0 || adapterPosition >= cards.size) {
return
}
val cardAtPosition = cards[adapterPosition]
val contentCardView = getContentCardsViewFromCache(context, cardAtPosition.cardType)
if (viewHolder != null) {
contentCardView!!.bindViewHolder(viewHolder, cardAtPosition)
}
}
override fun getItemViewType(context: Context?, cards: List<Card>, adapterPosition: Int): Int {
if (adapterPosition < 0 || adapterPosition >= cards.size) {
return -1
}
val card = cards[adapterPosition]
return card.cardType.value
}
/**
* Gets a cached instance of a [BaseContentCardView] for view creation/binding for a given [CardType].
* If the [CardType] is not found in the cache, then a view binding implementation for that [CardType]
* is created and added to the cache.
*/
@VisibleForTesting
fun getContentCardsViewFromCache(context: Context?, cardType: CardType): BaseContentCardView<Card>? {
if (!mContentCardViewCache.containsKey(cardType)) {
// Create the view here
val contentCardView: BaseContentCardView<*> = when (cardType) {
CardType.BANNER -> BannerImageContentCardView(context)
CardType.CAPTIONED_IMAGE -> CaptionedImageContentCardView(context)
CardType.SHORT_NEWS -> ShortNewsContentCardView(context)
CardType.TEXT_ANNOUNCEMENT -> TextAnnouncementContentCardView(context)
else -> DefaultContentCardView(context)
}
mContentCardViewCache[cardType] = contentCardView
}
return mContentCardViewCache[cardType] as BaseContentCardView<Card>?
}
// Parcelable interface method
override fun describeContents(): Int {
return 0
}
// Parcelable interface method
override fun writeToParcel(dest: Parcel?, flags: Int) {
// Retaining views across a transition could lead to a
// resource leak so the parcel is left unmodified
}
}
This code can also be found here DefaultContentCardsViewBindingHandler
.
And here’s how to use this class:
1
2
3
4
IContentCardsViewBindingHandler viewBindingHandler = new DefaultContentCardsViewBindingHandler();
ContentCardsFragment fragment = getMyCustomFragment();
fragment.setContentCardsViewBindingHandler(viewBindingHandler);
1
2
3
4
val viewBindingHandler = DefaultContentCardsViewBindingHandler()
val fragment = getMyCustomFragment()
fragment.setContentCardsViewBindingHandler(viewBindingHandler)
Additional relevant resources on this topic are available in this article on Android Data Binding.
To fully customize cards in Jetpack Compose, create a custom Composable function does the following:
- Render the Composable and return
true
. - Render nothing and return
false
. Whenfalse
is returned, Braze will render the card.
In the following example, the Composable function renders TEXT_ANNOUNCEMENT
cards, while Braze automatically renders the rest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
val myCustomCardRenderer: @Composable ((Card) -> Boolean) = { card ->
if (card.cardType == CardType.TEXT_ANNOUNCEMENT) {
val textCard = card as TextAnnouncementCard
Box(
Modifier
.padding(10.dp)
.fillMaxWidth()
.background(color = Color.Red)
) {
Text(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.basicMarquee(iterations = Int.MAX_VALUE),
fontSize = 35.sp,
text = textCard.description
)
}
true
} else {
false
}
}
ContentCardsList(
customCardComposer = myCustomCardRenderer
)
Card dismissal
Disabling swipe-to-dismiss functionality is done on a per-card basis via the card.isDismissibleByUser()
method. Cards can be intercepted before display using the ContentCardsFragment.setContentCardUpdateHandler()
method.
Dark theme customization
By default, Content Card views will automatically respond to Dark Theme changes on the device with a set of themed colors and layout changes.
To override this behavior, override the values-night
values in android-sdk-ui/src/main/res/values-night/colors.xml
and android-sdk-ui/src/main/res/values-night/dimens.xml
.
Logging impressions, clicks, and dismissals
After extending your custom objects to function as Content Cards, logging valuable metrics like impressions, clicks, and dismissals can be done by using a ContentCardable
base class that references and provides data to the BrazeManager
.
Implementation components
Custom objects call the logging methods
Within your ContentCardable
base class, you can call the BrazeManager
directly, if appropriate. In this example, the cardData
property will be non-null if the object came from a Content Card.
1
2
3
4
5
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val tile = currentTiles[position]
tile.logContentCardImpression()
...
}
Retrieve the Content Card from the ContentCardId
The ContentCardable
base class handles the heavy lifting of calling the BrazeManager
and passing the unique identifier from the Content Card associated with the custom object.
1
2
3
fun logContentCardImpression() {
cardData?.let { BrazeManager.getInstance().logContentCardImpression(it.contentCardId) }
}
Call Card
functions
The BrazeManager
can reference Braze SDK dependencies such as the Content Card objects array list to get the Card
to call our logging methods.
1
2
3
4
5
6
7
8
9
10
11
fun logContentCardClicked(idString: String?) {
getContentCard(idString)?.logClick()
}
fun logContentCardImpression(idString: String?) {
getContentCard(idString)?.logImpression()
}
private fun getContentCard(idString: String?): Card? {
return cardList.find { it.id == idString }.takeIf { it != null }
}
Custom objects call the logging methods
Within your ContentCardable
base class, you can call the BrazeManager
directly, if appropriate. Remember, in this example, the cardData
property will be non-null if the object came from a Content Card.
1
2
3
4
5
6
@Override
public View getView(int position, View convertView, ViewGroup parent) {
Tile tile = currentTiles.get(position);
tile.logContentCardImpression();
...
}
Retrieve the Content Card from the ContentCardId
The ContentCardable
base class handles the heavy lifting of calling the BrazeManager
and passing the unique identifier from the Content Card associated with the custom object.
1
2
3
4
5
public void logContentCardImpression() {
if (cardData != null){
BrazeManager.getInstance().logContentCardImpression(cardData.getContentCardId());
}
}
Call Card
functions
The BrazeManager
can reference Braze SDK dependencies such as the Content Card objects array list to get the Card
to call our logging methods.
1
2
3
4
5
6
7
8
9
10
11
public void logContentCardClicked(String idString) {
getContentCard(idString).ifPresent(Card::logClick);
}
public void logContentCardImpression(String idString) {
getContentCard(idString).ifPresent(Card::logImpression);
}
private Optional<Card> getContentCard(String idString) {
return cardList.filter(c -> c.id.equals(idString)).findAny();
}
For a control variant Content Card, a custom object should still be instantiated, and UI logic should set the object’s corresponding view as hidden. The object can then log an impression to inform our analytics of when a user would have seen the control card.
Helper files
ContentCardKey Helper File
1
2
3
4
5
6
7
companion object Keys{
const val idString = "idString"
const val created = "created"
const val classType = "class_type"
const val dismissable = "dismissable"
//...
}
1
2
3
4
5
public static final String IDSTRING = "idString";
public static final String CREATED = "created";
public static final String CLASSTYPE = "class_type";
public static final String DISMISSABLE = "dismissable";
...