Customization

Default Styling

Braze In App Messages and Content Cards come with a default look and feel that matches the Android standard UI guidelines and provide a seamless experience. You can see these default styles in the res/values/styles.xml file in the Braze SDK distribution.

1
2
3
4
5
6
7
8
9
10
11
12
  <!-- Content Cards Example -->
  <style name="Appboy.ContentCards.CaptionedImage.Description">
    <item name="android:textColor">@color/com_appboy_description</item>
    <item name="android:textSize">15.0sp</item>
    <item name="android:includeFontPadding">false</item>
    <item name="android:paddingBottom">8.0dp</item>
    <item name="android:layout_marginLeft">10.0dp</item>
    <item name="android:layout_marginRight">10.0dp</item>
    <item name="android:layout_marginTop">8.0dp</item>
    <item name="android:layout_width">match_parent</item>
    <item name="android:layout_below">@id/com_appboy_content_cards_captioned_image_card_title_container</item>
  </style>

Overriding Styles

If you would prefer, you can override these styles to create a look and feel that better suits your app. To override a style, copy it in its entirety to the styles.xml file in your own project and make modifications. The whole style must be copied over to your local styles.xml file in order for all of the attributes to be correctly set.

Correct Style Override

1
2
3
4
5
6
7
8
9
<style name="Appboy.ContentCardsDisplay">
  <item name="android:background">@color/mint</item>
  <item name="android:cacheColorHint">@color/mint</item>
  <item name="android:divider">@android:color/transparent</item>
  <item name="android:dividerHeight">16.0dp</item>
  <item name="android:paddingLeft">12.5dp</item>
  <item name="android:paddingRight">5.0dp</item>
  <item name="android:scrollbarStyle">outsideInset</item>
</style>

Incorrect Style Override

1
2
3
4
<style name="Appboy.ContentCardsDisplay">
  <item name="android:background">@color/mint</item>
  <item name="android:cacheColorHint">@color/mint</item>
</style>

Content Cards Style Elements

Setting A Custom Font

Braze allows for setting a custom font using the font family guide. To use it, override a style for cards and use the fontFamily attribute to instruct Braze to use your custom font family.

For example, to update the font on all titles for Captioned Image Cards, override the Appboy.ContentCards.CaptionedImage.Title style and reference your custom font family. The attribute value should point to a font family in your res/font directory.

Here is a truncated example with a custom font family, my_custom_font_family, referenced on the last line:

1
2
3
4
5
6
  <style name="Appboy.ContentCards.CaptionedImage.Title">
    <item name="android:layout_width">wrap_content</item>
    ...
    <item name="android:fontFamily">@font/my_custom_font_family</item>
    <item name="fontFamily">@font/my_custom_font_family</item>
  </style>

Setting A Custom Pinned Icon

To set a custom pinned icon, override the Appboy.ContentCards.PinnedIcon style. Your custom image asset should be declared in the android:src element.

Customizing Displayed Card Order

The AppboyContentCardsFragment relies on a IContentCardsUpdateHandler to handle any sorting or modifications of Content Cards before they are displayed in the feed. A custom update handler can be set via setContentCardUpdateHandler on your AppboyContentCardsFragment.

Filtering out Content Cards before they reach the user’s feed is a common use-case and could be achieved by reading the key-value pairs set on the dashboard via Card.getExtras() and performing any logic you’d like in the update handler.

The following is the default IContentCardsUpdateHandler and can be used as a starting point for customizations.

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
public class DefaultContentCardsUpdateHandler implements IContentCardsUpdateHandler {
  @Override
  public List<Card> handleCardUpdate(ContentCardsUpdatedEvent event) {
    List<Card> sortedCards = event.getAllCards();
    // Sort by pinned, then by the 'updated' timestamp descending
    // Pinned before non-pinned
    Collections.sort(sortedCards, new Comparator<Card>() {
      @Override
      public int compare(Card cardA, Card cardB) {
        // A displays above B
        if (cardA.getIsPinned() && !cardB.getIsPinned()) {
          return -1;
        }

        // B displays above A
        if (!cardA.getIsPinned() && cardB.getIsPinned()) {
          return 1;
        }

        // At this point, both A & B are pinned or both A & B are non-pinned
        // A displays above B since A is newer
        if (cardA.getUpdated() > cardB.getUpdated()) {
          return -1;
        }

        // B displays above A since A is newer
        if (cardA.getUpdated() < cardB.getUpdated()) {
          return 1;
        }

        // At this point, every sortable field matches so keep the natural ordering
        return 0;
      }
    });

    return sortedCards;
  }
}
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
class DefaultContentCardsUpdateHandler : IContentCardsUpdateHandler {
  override fun handleCardUpdate(event: ContentCardsUpdatedEvent): List<Card> {
    val sortedCards = event.allCards
    // Sort by pinned, then by the 'updated' timestamp descending
    // Pinned before non-pinned
    Collections.sort(sortedCards, Comparator { cardA, cardB ->
      // A displays above B
      if (cardA.isPinned && !cardB.isPinned) {
        return@Comparator -1
      }

      // B displays above A
      if (!cardA.isPinned && cardB.isPinned) {
        return@Comparator 1
      }

      // At this point, both A & B are pinned or both A & B are non-pinned
      // A displays above B since A is newer
      if (cardA.updated > cardB.updated) {
        return@Comparator -1
      }

      // B displays above A since A is newer
      if (cardA.updated < cardB.updated) {
        1
      } else 0

      // At this point, every sortable field matches so keep the natural ordering
    })

    return sortedCards
  }
}

This code can also be found here, DefaultContentCardsUpdateHandler.

And here’s how to use the above class:

1
2
3
4
IContentCardsUpdateHandler cardUpdateHandler = new DefaultContentCardsUpdateHandler();

AppboyContentCardsFragment fragment = getMyCustomFragment();
fragment.setContentCardUpdateHandler(cardUpdateHandler);
1
2
3
4
val cardUpdateHandler = DefaultContentCardsUpdateHandler()

val fragment = getMyCustomFragment()
fragment.setContentCardUpdateHandler(cardUpdateHandler)

Customizing Card Rendering

Here’s 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
public class DefaultContentCardsViewBindingHandler implements IContentCardsViewBindingHandler {
  /**
   * 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<Card> cards, ViewGroup viewGroup, int viewType) {
    CardType cardType = CardType.fromValue(viewType);
    return getContentCardsViewFromCache(context, cardType).createViewHolder(viewGroup);
  }

  @Override
  public void onBindViewHolder(Context context, List<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<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);
  }
}
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
class DefaultContentCardsViewBindingHandler : IContentCardsViewBindingHandler {
  /**
   * A cache for the views used in binding the items in the [android.support.v7.widget.RecyclerView].
   */
  private val mContentCardViewCache = HashMap<CardType, BaseContentCardView<*>>()

  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) {
    val cardAtPosition = cards[adapterPosition]
    val contentCardView = getContentCardsViewFromCache(context, cardAtPosition.cardType)
    contentCardView?.bindViewHolder(viewHolder, cardAtPosition)
  }

  override fun getItemViewType(context: Context, cards: List<Card>, adapterPosition: Int): Int {
    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
  @NonNull
  internal fun getContentCardsViewFromCache(context: Context, cardType: CardType): BaseContentCardView<*>? {
    if (!mContentCardViewCache.containsKey(cardType)) {
      // Create the view here
      val contentCardView: BaseContentCardView<*>
      when (cardType) {
        CardType.BANNER -> contentCardView = BannerImageContentCardView(context)
        CardType.CAPTIONED_IMAGE -> contentCardView = CaptionedImageContentCardView(context)
        CardType.SHORT_NEWS -> contentCardView = ShortNewsContentCardView(context)
        CardType.TEXT_ANNOUNCEMENT -> contentCardView = TextAnnouncementContentCardView(context)
        else -> contentCardView = DefaultContentCardView(context)
      }
      mContentCardViewCache[cardType] = contentCardView
    }
    return mContentCardViewCache[cardType]
  }
}

And here’s how to use the above class:

1
2
3
4
IContentCardsViewBindingHandler viewBindingHandler = new DefaultContentCardsViewBindingHandler();

AppboyContentCardsFragment fragment = getMyCustomFragment();
fragment.setContentCardsViewBindingHandler(viewBindingHandler);
1
2
3
4
val viewBindingHandler = DefaultContentCardsViewBindingHandler()

val fragment = getMyCustomFragment()
fragment.setContentCardsViewBindingHandler(viewBindingHandler)

There are additional relevant resources on this topic available here.

Setting a Custom Content Cards Click Listener

You can handle Content Cards clicks manually by setting a custom click listener. This enables use cases such as selectively using the native web browser to open web links.

Step 1: Implement a Content Cards Click Listener

Create a class that implements IContentCardsActionListener and register it with AppboyContentCardsManager. Implement the onContentCardClicked() method, which will be called when the user clicks a content card.

Step 2: Instruct Braze to Use Your Content Card Click Listener

You can see an example of steps 1 and 2 here:

1
2
3
4
5
6
7
8
9
10
11
AppboyContentCardsManager.getInstance().setContentCardsActionListener(new IContentCardsActionListener() {
  @Override
  public boolean onContentCardClicked(Context context, Card card, IAction cardAction) {
    return false;
  }

  @Override
  public void onContentCardDismissed(Context context, Card card) {

  }
});
1
2
3
4
5
6
7
8
9
AppboyContentCardsManager.getInstance().contentCardsActionListener = object : IContentCardsActionListener {
  override fun onContentCardClicked(context: Context, card: Card, cardAction: IAction): Boolean {
    return false
  }

  override fun onContentCardDismissed(context: Context, card: Card) {

  }
}

Fully Custom Content Card Display

If you would like to display the Content Cards in a completely custom manner, it is possible to do so by using your own views populated with data from our models. To obtain Braze’s content cards models, you will need to subscribe for content card updates and use the resulting model data to populate your views. You will also need to log analytics on the model objects as users interact with your views.

Part 1: Subscribing to Content Card Updates

First, declare a private variable in your custom class to hold your subscriber:

1
2
// subscriber variable
private IEventSubscriber<ContentCardsUpdatedEvent> mContentCardsUpdatedSubscriber;
1
private val mContentCardsUpdatedSubscriber: IEventSubscriber<ContentCardsUpdatedEvent>? = null

Next, add the following code to subscribe to Content Card updates from Braze, typically inside of your custom Content Cards activity’s Activity.onCreate():

1
2
3
4
5
6
7
8
9
10
11
12
13
// Remove the previous subscriber before rebuilding a new one with our new activity.
Appboy.getInstance(context).removeSingleSubscription(mContentCardsUpdatedSubscriber, ContentCardsUpdatedEvent.class);
mContentCardsUpdatedSubscriber = new IEventSubscriber<ContentCardsUpdatedEvent>() {
    @Override
    public void trigger(ContentCardsUpdatedEvent event) {
        // List of all content cards
        List<Card> allCards = event.getAllCards();

        // Your logic below
    }
};
Appboy.getInstance(context).subscribeToContentCardsUpdates(mContentCardsUpdatedSubscriber);
Appboy.getInstance(context).requestContentCardsRefresh(true);
1
2
3
4
5
6
7
8
9
10
// Remove the previous subscriber before rebuilding a new one with our new activity.
Appboy.getInstance(context).removeSingleSubscription(mContentCardsUpdatedSubscriber, ContentCardsUpdatedEvent::class.java)
mContentCardsUpdatedSubscriber = IEventSubscriber { event ->
  // List of all content cards
  val allCards = event.allCards

  // Your logic below
}
Appboy.getInstance(context).subscribeToContentCardsUpdates(mContentCardsUpdatedSubscriber)
Appboy.getInstance(context).requestContentCardsRefresh(true)

We also recommend unsubscribing when your custom activity moves out of view. Add the following code to your activity’s onDestroy() lifecycle method:

1
Appboy.getInstance(context).removeSingleSubscription(mContentCardsUpdatedSubscriber, ContentCardsUpdatedEvent.class);
1
Appboy.getInstance(context).removeSingleSubscription(mContentCardsUpdatedSubscriber, ContentCardsUpdatedEvent::class.java)

Part 2: Logging Analytics

When using custom views, you will need to log analytics manually as well, since analytics are only handled automatically when using Braze views.

To log a display of the Content Cards, call Appboy.logContentCardsDisplayed().

To log an impression or click on a Card, call Card.logClick() or Card.logImpression() respectively.

Manually Dismissing a Content Card

You can manually log or set a Content Card as “dismissed” to Braze for a particular card with setIsDismissed.

If a card is already marked as dismissed, it cannot be marked as dismissed again.

Key-Value Pairs

Card objects may optionally carry key-value pairs as extras. These can be used to send data down along with a Card for further handling by the application.

See the Javadoc for more information.

GIFs

Braze requires an external image library to display animated GIFs with Content Cards.

Custom Image Library Integration

Braze offers the ability to use a custom image library to display animated GIFs with Content Cards.

Note: Although the example below uses Glide, any image library that supports GIFs is compatible.

Step 1: Creating the Image Loader Delegate

The Image Loader delegate must implement the following methods:

The integration example below is taken from the Glide Integration Sample App included with the Braze Android SDK.

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
public class GlideAppboyImageLoader implements IAppboyImageLoader {
  private static final String TAG = GlideAppboyImageLoader.class.getName();

  private RequestOptions mRequestOptions = new RequestOptions();

  @Override
  public void renderUrlIntoCardView(Context context, Card card, String imageUrl, ImageView imageView, AppboyViewBounds viewBounds) {
    renderUrlIntoView(context, imageUrl, imageView, viewBounds);
  }

  @Override
  public void renderUrlIntoInAppMessageView(Context context, IInAppMessage inAppMessage, String imageUrl, ImageView imageView, AppboyViewBounds viewBounds) {
    renderUrlIntoView(context, imageUrl, imageView, viewBounds);
  }

  @Override
  public Bitmap getPushBitmapFromUrl(Context context, Bundle extras, String imageUrl, AppboyViewBounds viewBounds) {
    return getBitmapFromUrl(context, imageUrl, viewBounds);
  }

  @Override
  public Bitmap getInAppMessageBitmapFromUrl(Context context, IInAppMessage inAppMessage, String imageUrl, AppboyViewBounds viewBounds) {
    return getBitmapFromUrl(context, imageUrl, viewBounds);
  }

  private void renderUrlIntoView(Context context, String imageUrl, ImageView imageView, AppboyViewBounds viewBounds) {
    Glide.with(context)
        .load(imageUrl)
        .apply(mRequestOptions)
        .into(imageView);
  }

  private Bitmap getBitmapFromUrl(Context context, String imageUrl, AppboyViewBounds viewBounds) {
    try {
      return Glide.with(context)
          .asBitmap()
          .apply(mRequestOptions)
          .load(imageUrl).submit().get();
    } catch (Exception e) {
      Log.e(TAG, "Failed to retrieve bitmap at url: " + imageUrl, e);
    }
    return null;
  }

  @Override
  public void setOffline(boolean isOffline) {
    // If the loader is offline, then we should only be retrieving from the cache
    mRequestOptions = mRequestOptions.onlyRetrieveFromCache(isOffline);
  }
}
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
class GlideAppboyImageLoader : IAppboyImageLoader {
  companion object {
    private val TAG = GlideAppboyImageLoader::class.qualifiedName
  }

  private var mRequestOptions = RequestOptions()

  override fun renderUrlIntoCardView(context: Context, card: Card, imageUrl: String, imageView: ImageView, viewBounds: AppboyViewBounds) {
    renderUrlIntoView(context, imageUrl, imageView, viewBounds)
  }

  override fun renderUrlIntoInAppMessageView(context: Context, inAppMessage: IInAppMessage, imageUrl: String, imageView: ImageView, viewBounds: AppboyViewBounds) {
    renderUrlIntoView(context, imageUrl, imageView, viewBounds)
  }

  override fun getPushBitmapFromUrl(context: Context, extras: Bundle, imageUrl: String, viewBounds: AppboyViewBounds): Bitmap? {
    return getBitmapFromUrl(context, imageUrl, viewBounds)
  }

  override fun getInAppMessageBitmapFromUrl(context: Context, inAppMessage: IInAppMessage, imageUrl: String, viewBounds: AppboyViewBounds): Bitmap? {
    return getBitmapFromUrl(context, imageUrl, viewBounds)
  }

  private fun renderUrlIntoView(context: Context, imageUrl: String, imageView: ImageView, viewBounds: AppboyViewBounds) {
    Glide.with(context)
        .load(imageUrl)
        .apply(mRequestOptions)
        .into(imageView)
  }

  private fun getBitmapFromUrl(context: Context, imageUrl: String, viewBounds: AppboyViewBounds): Bitmap? {
    try {
      return Glide.with(context)
          .asBitmap()
          .apply(mRequestOptions)
          .load(imageUrl).submit().get()
    } catch (e: Exception) {
      Log.e(TAG, "Failed to retrieve bitmap at url: $imageUrl", e)
    }

    return null
  }

  override fun setOffline(isOffline: Boolean) {
    // If the loader is offline, then we should only be retrieving from the cache
    mRequestOptions = mRequestOptions.onlyRetrieveFromCache(isOffline)
  }
}

Step 2: Setting the Image Loader Delegate

The Braze SDK will use any custom image loader set with setAppboyImageLoader. Note that we recommend setting the custom image loader in a custom application subclass.

1
2
3
4
5
6
7
public class GlideIntegrationApplication extends Application {
  @Override
  public void onCreate() {
    super.onCreate();
    Appboy.getInstance(context).setAppboyImageLoader(new GlideAppboyImageLoader());
  }
}
1
2
3
4
5
6
class GlideIntegrationApplication : Application() {
  override fun onCreate() {
    super.onCreate()
    Appboy.getInstance(context).appboyImageLoader = GlideAppboyImageLoader()
  }
}

Fresco Migration

Fresco is no longer supported as a GIF loading library in version 3.0.0 of the Braze Android SDK. A custom image loader, such as Glide, must be used in order to display GIFs.

The usage of Fresco with IAppboyImageLoader is not supported since Fresco requires Drawee views to work.

WAS THIS PAGE HELPFUL?
New Stuff!