Skip to content

Commit eb2771e

Browse files
Add opt-in customizable toolbar to IterableInboxFragment
1 parent d457147 commit eb2771e

11 files changed

Lines changed: 314 additions & 4 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [Unreleased]
66
### Added
7+
- New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`:
8+
- `None` (default) — no toolbar; behavior is unchanged from prior SDK versions.
9+
- `Default` — title-only toolbar above the inbox list.
10+
- `WithBackButton` — title plus a back navigation icon. The default back action calls `OnBackPressedDispatcher`; override it by having the host Activity or parent Fragment implement `IterableInboxToolbarBackListener`.
11+
- `Custom(layoutRes)` — inflates the integrator's own toolbar layout. Views tagged with `@id/iterable_inbox_back_button` and `@id/iterable_inbox_title` are auto-wired to the SDK's back handler and title binding respectively. Both ids are optional.
12+
- Configure programmatically via `IterableInboxFragment.newInstance(...)` (new 2-arg and 6-arg overloads) or via `IterableInboxActivity` intent extras (`TOOLBAR_OPTION` / `TOOLBAR_TITLE`).
13+
- Requires the host activity to use a `Theme.AppCompat` / `Theme.MaterialComponents` / `Theme.Material3` descendant when the toolbar is enabled.
714
- New `IterableInAppDisplayMode` enum to control how in-app messages interact with system bars. Configure via `IterableConfig.Builder.setInAppDisplayMode()`:
815
- `FORCE_EDGE_TO_EDGE` (default) — draws in-app content behind system bars with transparent status and navigation bars. This preserves the previous SDK behavior.
916
- `FOLLOW_APP_LAYOUT` — matches the host app's system bar configuration automatically.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.iterable.iterableapi.ui.inbox
2+
3+
import androidx.annotation.LayoutRes
4+
import java.io.Serializable
5+
6+
/**
7+
* Controls how [IterableInboxToolbarView] renders. Passed as a fragment argument or
8+
* intent extra on [IterableInboxFragment] / [IterableInboxActivity].
9+
*/
10+
sealed interface InboxToolbarOption : Serializable {
11+
12+
/** No toolbar. The fragment renders identically to prior SDK versions. */
13+
data object None : InboxToolbarOption
14+
15+
/** A title-only toolbar above the inbox list. */
16+
data object Default : InboxToolbarOption
17+
18+
/** A toolbar with the configured title plus a back navigation icon. */
19+
data object WithBackButton : InboxToolbarOption
20+
21+
/**
22+
* Inflates a fully custom toolbar layout supplied by the integrator. The integrator
23+
* owns all wiring for their own views (menus, clicks, icons, etc.).
24+
*
25+
* Opt-in conventions - the SDK looks up these ids via `findViewById` and, if present,
26+
* auto-wires them. Both lookups are null-safe; omitting either id keeps the SDK from
27+
* touching that view.
28+
*
29+
* - `@id/iterable_inbox_back_button` - auto-wired to the SDK's default back
30+
* handler. Override the action by implementing [IterableInboxToolbarBackListener]
31+
* on the host Activity or parent Fragment.
32+
* - `@id/iterable_inbox_title` - if the view is a `TextView`, the SDK sets its
33+
* text to the `toolbarTitle` argument (or the default "Inbox" string when null).
34+
*/
35+
data class Custom(@LayoutRes val layoutRes: Int) : InboxToolbarOption
36+
}

iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,28 @@
44
import android.os.Bundle;
55
import androidx.annotation.Nullable;
66
import androidx.appcompat.app.AppCompatActivity;
7+
import androidx.core.content.IntentCompat;
78

89
import com.iterable.iterableapi.IterableConstants;
910
import com.iterable.iterableapi.IterableLogger;
1011
import com.iterable.iterableapi.ui.R;
1112

1213
import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.INBOX_MODE;
1314
import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.ITEM_LAYOUT_ID;
15+
import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_OPTION;
16+
import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_TITLE;
1417

1518
/**
1619
* An activity wrapping {@link IterableInboxFragment}
1720
* <p>
1821
* Supports optional extras:
1922
* {@link IterableInboxFragment#INBOX_MODE} - {@link InboxMode} value with the inbox mode
2023
* {@link IterableInboxFragment#ITEM_LAYOUT_ID} - Layout resource id for inbox items
24+
* {@link IterableInboxFragment#TOOLBAR_OPTION} - {@link InboxToolbarOption} variant for the opt-in inbox toolbar
25+
* {@link IterableInboxFragment#TOOLBAR_TITLE} - Title shown in the opt-in inbox toolbar
26+
* {@link IterableConstants#NO_MESSAGES_TITLE} - Title for the empty-inbox state
27+
* {@link IterableConstants#NO_MESSAGES_BODY} - Body for the empty-inbox state
28+
* {@link #ACTIVITY_TITLE} - Title set on the activity via {@code setTitle()}
2129
*/
2230
public class IterableInboxActivity extends AppCompatActivity {
2331
private static final String TAG = "IterableInboxActivity";
@@ -45,7 +53,14 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
4553
noMessageTitle = extraBundle.getString(IterableConstants.NO_MESSAGES_TITLE, null);
4654
noMessageBody = extraBundle.getString(IterableConstants.NO_MESSAGES_BODY, null);
4755
}
48-
inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody);
56+
57+
InboxToolbarOption toolbarOption = IntentCompat.getSerializableExtra(intent, TOOLBAR_OPTION, InboxToolbarOption.class);
58+
if (toolbarOption == null) {
59+
toolbarOption = InboxToolbarOption.None.INSTANCE;
60+
}
61+
String toolbarTitle = intent.getStringExtra(TOOLBAR_TITLE);
62+
63+
inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody, toolbarOption, toolbarTitle);
4964

5065
if (intent.getStringExtra(ACTIVITY_TITLE) != null) {
5166
setTitle(intent.getStringExtra(ACTIVITY_TITLE));

iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.iterable.iterableapi.ui.inbox;
22

3+
import android.content.Context;
34
import android.content.Intent;
45
import android.graphics.Insets;
56
import android.os.Build;
67
import android.os.Bundle;
78
import androidx.annotation.LayoutRes;
89
import androidx.annotation.NonNull;
910
import androidx.annotation.Nullable;
11+
import androidx.core.os.BundleCompat;
1012
import androidx.core.view.ViewCompat;
1113
import androidx.fragment.app.Fragment;
1214
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -35,19 +37,29 @@
3537
* The main class for Inbox UI. Renders the list of Inbox messages and handles touch interaction:
3638
* tap on an item opens the in-app message, swipe left deletes it.
3739
* <p>
38-
* To customize the UI, either create the fragment with {@link #newInstance(InboxMode, int)},
39-
* or subclass {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)},
40+
* To customize the UI, create the fragment with one of the {@code newInstance(...)} overloads
41+
* (use {@link InboxToolbarOption} to opt into the built-in toolbar), or subclass
42+
* {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)},
4043
* {@link #setComparator(IterableInboxComparator)} and {@link #setFilter(IterableInboxFilter)}.
44+
* Implement {@link IterableInboxToolbarBackListener} on the host to handle toolbar back clicks.
45+
* <p>
46+
* The host activity must use a {@code Theme.AppCompat} (or {@code Theme.MaterialComponents} /
47+
* {@code Theme.Material3}) descendant when the opt-in toolbar is enabled.
4148
*/
4249
public class IterableInboxFragment extends Fragment implements IterableInAppManager.Listener, IterableInboxAdapter.OnListInteractionListener {
4350
private static final String TAG = "IterableInboxFragment";
4451
public static final String INBOX_MODE = "inboxMode";
4552
public static final String ITEM_LAYOUT_ID = "itemLayoutId";
53+
public static final String TOOLBAR_OPTION = "toolbarOption";
54+
public static final String TOOLBAR_TITLE = "toolbarTitle";
4655

4756
private InboxMode inboxMode = InboxMode.POPUP;
4857
private @LayoutRes int itemLayoutId = R.layout.iterable_inbox_item;
4958
private String noMessagesTitle;
5059
private String noMessagesBody;
60+
private InboxToolbarOption toolbarOption = InboxToolbarOption.None.INSTANCE;
61+
private @Nullable String toolbarTitle;
62+
private @Nullable IterableInboxToolbarBackListener toolbarBackListener;
5163
TextView noMessagesTitleTextView;
5264
TextView noMessagesBodyTextView;
5365
RecyclerView recyclerView;
@@ -93,6 +105,41 @@ public class IterableInboxFragment extends Fragment implements IterableInAppMana
93105
return inboxFragment;
94106
}
95107

108+
/**
109+
* Create an Inbox fragment with toolbar customization; all other parameters use their defaults.
110+
*
111+
* @param toolbarOption Toolbar variant
112+
* @param toolbarTitle Title shown in the toolbar, or null for the default "Inbox" string
113+
* @return {@link IterableInboxFragment} instance
114+
*/
115+
@NonNull public static IterableInboxFragment newInstance(
116+
@NonNull InboxToolbarOption toolbarOption,
117+
@Nullable String toolbarTitle
118+
) {
119+
return newInstance(InboxMode.POPUP, 0, null, null, toolbarOption, toolbarTitle);
120+
}
121+
122+
@NonNull public static IterableInboxFragment newInstance(
123+
@NonNull InboxMode inboxMode,
124+
@LayoutRes int itemLayoutId,
125+
@Nullable String noMessagesTitle,
126+
@Nullable String noMessagesBody,
127+
@NonNull InboxToolbarOption toolbarOption,
128+
@Nullable String toolbarTitle
129+
) {
130+
IterableInboxFragment inboxFragment = new IterableInboxFragment();
131+
Bundle bundle = new Bundle();
132+
bundle.putSerializable(INBOX_MODE, inboxMode);
133+
bundle.putInt(ITEM_LAYOUT_ID, itemLayoutId);
134+
bundle.putString(IterableConstants.NO_MESSAGES_TITLE, noMessagesTitle);
135+
bundle.putString(IterableConstants.NO_MESSAGES_BODY, noMessagesBody);
136+
bundle.putSerializable(TOOLBAR_OPTION, toolbarOption);
137+
bundle.putString(TOOLBAR_TITLE, toolbarTitle);
138+
inboxFragment.setArguments(bundle);
139+
140+
return inboxFragment;
141+
}
142+
96143
/**
97144
* Set the inbox mode to display inbox messages either in a new activity or as an overlay
98145
*
@@ -147,6 +194,23 @@ protected void setDateMapper(@NonNull IterableInboxDateMapper dateMapper) {
147194
}
148195
}
149196

197+
@Override
198+
public void onAttach(@NonNull Context context) {
199+
super.onAttach(context);
200+
Fragment parent = getParentFragment();
201+
if (parent instanceof IterableInboxToolbarBackListener) {
202+
toolbarBackListener = (IterableInboxToolbarBackListener) parent;
203+
} else if (context instanceof IterableInboxToolbarBackListener) {
204+
toolbarBackListener = (IterableInboxToolbarBackListener) context;
205+
}
206+
}
207+
208+
@Override
209+
public void onDetach() {
210+
toolbarBackListener = null;
211+
super.onDetach();
212+
}
213+
150214
@Override
151215
public void onCreate(@Nullable Bundle savedInstanceState) {
152216
super.onCreate(savedInstanceState);
@@ -171,9 +235,30 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
171235
if (arguments.getString(IterableConstants.NO_MESSAGES_BODY) != null) {
172236
noMessagesBody = arguments.getString(IterableConstants.NO_MESSAGES_BODY);
173237
}
238+
InboxToolbarOption toolbarOptionArg = BundleCompat.getSerializable(arguments, TOOLBAR_OPTION, InboxToolbarOption.class);
239+
if (toolbarOptionArg != null) {
240+
toolbarOption = toolbarOptionArg;
241+
}
242+
if (arguments.getString(TOOLBAR_TITLE) != null) {
243+
toolbarTitle = arguments.getString(TOOLBAR_TITLE);
244+
}
174245
}
175246

176247
RelativeLayout relativeLayout = (RelativeLayout) inflater.inflate(R.layout.iterable_inbox_fragment, container, false);
248+
249+
IterableInboxToolbarView toolbar = relativeLayout.findViewById(R.id.iterable_inbox_toolbar);
250+
toolbar.apply(toolbarOption, toolbarTitle);
251+
// Prefer the host listener if one was discovered in onAttach; otherwise delegate
252+
// to the fragment's host activity so we never depend on the view's Context chain
253+
// to find a ComponentActivity.
254+
if (toolbarBackListener != null) {
255+
toolbar.setOnBackClickListener(v -> toolbarBackListener.onInboxToolbarBackClick());
256+
} else {
257+
toolbar.setOnBackClickListener(v ->
258+
requireActivity().getOnBackPressedDispatcher().onBackPressed()
259+
);
260+
}
261+
177262
recyclerView = relativeLayout.findViewById(R.id.list);
178263
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
179264
IterableInboxAdapter adapter = new IterableInboxAdapter(IterableApi.getInstance().getInAppManager().getInboxMessages(), IterableInboxFragment.this, adapterExtension, comparator, filter, dateMapper);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.iterable.iterableapi.ui.inbox;
2+
3+
/**
4+
* Implement on the host Activity or parent Fragment of {@link IterableInboxFragment}
5+
* to handle back-navigation taps from the opt-in inbox toolbar.
6+
*
7+
* <p>Relevant when toolbar option is {@code InboxToolbarOption.WithBackButton} or a
8+
* {@code InboxToolbarOption.Custom} layout includes a view with id
9+
* {@code @id/iterable_inbox_back_button}. If no host implements this interface, the
10+
* fragment falls back to the host activity's {@code OnBackPressedDispatcher}.
11+
*
12+
* <p>The listener is discovered during {@code onAttach()}, so it survives process
13+
* death - recreated fragments re-bind to the restored host automatically.
14+
*/
15+
public interface IterableInboxToolbarBackListener {
16+
void onInboxToolbarBackClick();
17+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.iterable.iterableapi.ui.inbox
2+
3+
import android.content.Context
4+
import android.util.AttributeSet
5+
import android.view.LayoutInflater
6+
import android.view.View
7+
import android.widget.FrameLayout
8+
import android.widget.TextView
9+
import androidx.activity.ComponentActivity
10+
import androidx.annotation.LayoutRes
11+
import com.google.android.material.appbar.MaterialToolbar
12+
import com.iterable.iterableapi.ui.R
13+
14+
/**
15+
* Opt-in toolbar for [IterableInboxFragment]. Configure via [apply] with an
16+
* [InboxToolbarOption].
17+
*
18+
* The view is marked `gone` in the fragment layout by default, so `InboxToolbarOption.None`
19+
* (the SDK default) renders no toolbar and takes no space.
20+
*
21+
* **Theme requirement:** the host activity must use a `Theme.AppCompat` (or
22+
* `Theme.MaterialComponents` / `Theme.Material3`) descendant - `MaterialToolbar` will throw
23+
* an `InflateException` otherwise. If using the [IterableInboxActivity] this is a non-concern
24+
*/
25+
class IterableInboxToolbarView @JvmOverloads constructor(
26+
context: Context,
27+
attrs: AttributeSet? = null,
28+
defStyleAttr: Int = 0
29+
) : FrameLayout(context, attrs, defStyleAttr) {
30+
31+
private var materialToolbar: MaterialToolbar
32+
private var backClickListener: OnClickListener? = null
33+
private var isCustomLayout = false
34+
35+
init {
36+
LayoutInflater.from(context).inflate(R.layout.iterable_inbox_toolbar, this, true)
37+
materialToolbar = findViewById(R.id.iterableInboxMaterialToolbar)
38+
}
39+
40+
/** Configure the toolbar. Safe to call multiple times (e.g. on config change). */
41+
fun apply(option: InboxToolbarOption, title: String?) {
42+
when (option) {
43+
InboxToolbarOption.None -> {
44+
visibility = View.GONE
45+
}
46+
InboxToolbarOption.Default -> {
47+
showDefaultLayout()
48+
visibility = View.VISIBLE
49+
materialToolbar.title = resolveTitle(title)
50+
materialToolbar.navigationIcon = null
51+
materialToolbar.setNavigationOnClickListener(null)
52+
}
53+
InboxToolbarOption.WithBackButton -> {
54+
showDefaultLayout()
55+
visibility = View.VISIBLE
56+
materialToolbar.title = resolveTitle(title)
57+
materialToolbar.setNavigationIcon(R.drawable.ic_arrow_back_black_24dp)
58+
materialToolbar.setNavigationOnClickListener { v -> dispatchBackClick(v) }
59+
}
60+
is InboxToolbarOption.Custom -> {
61+
showCustomLayout(option.layoutRes)
62+
visibility = View.VISIBLE
63+
findViewById<View>(R.id.iterable_inbox_back_button)?.setOnClickListener { v ->
64+
dispatchBackClick(v)
65+
}
66+
// `as? TextView` also matches subclasses like Button/EditText - documented behavior.
67+
(findViewById<View>(R.id.iterable_inbox_title) as? TextView)?.text = resolveTitle(title)
68+
}
69+
}
70+
}
71+
72+
/**
73+
* Override the default back-click behavior. Honored for
74+
* `InboxToolbarOption.WithBackButton` and for `InboxToolbarOption.Custom` layouts that
75+
* include a view with id `@id/iterable_inbox_back_button`. Pass `null` to clear a
76+
* previously set override and fall back to the host activity's `OnBackPressedDispatcher`.
77+
*
78+
* When this view is hosted by [IterableInboxFragment], the fragment always installs
79+
* a default listener, so this fallback path is only reached for standalone XML usage.
80+
*/
81+
fun setOnBackClickListener(listener: OnClickListener?) {
82+
backClickListener = listener
83+
}
84+
85+
private fun dispatchBackClick(v: View) {
86+
val override = backClickListener
87+
if (override != null) {
88+
override.onClick(v)
89+
return
90+
}
91+
(context as? ComponentActivity)?.onBackPressedDispatcher?.onBackPressed()
92+
}
93+
94+
/** Swap in the SDK's default toolbar layout. No-op when it's already showing. */
95+
private fun showDefaultLayout() {
96+
if (!isCustomLayout) return
97+
removeAllViews()
98+
LayoutInflater.from(context).inflate(R.layout.iterable_inbox_toolbar, this, true)
99+
materialToolbar = findViewById(R.id.iterableInboxMaterialToolbar)
100+
isCustomLayout = false
101+
}
102+
103+
/** Swap in the integrator's custom toolbar layout. Always re-inflates. */
104+
private fun showCustomLayout(@LayoutRes layoutRes: Int) {
105+
removeAllViews()
106+
LayoutInflater.from(context).inflate(layoutRes, this, true)
107+
isCustomLayout = true
108+
}
109+
110+
private fun resolveTitle(title: String?): String =
111+
title ?: context.getString(R.string.iterable_inbox_default_title)
112+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector android:height="24dp"
2+
android:viewportHeight="24.0" android:viewportWidth="24.0"
3+
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
4+
<path android:fillColor="#FF000000" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
5+
</vector>

iterableapi-ui/src/main/res/layout/iterable_inbox_fragment.xml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@
99
android:layout_width="match_parent"
1010
tools:context=".inbox.IterableInboxFragment">
1111

12+
<com.iterable.iterableapi.ui.inbox.IterableInboxToolbarView
13+
android:id="@+id/iterable_inbox_toolbar"
14+
android:layout_width="match_parent"
15+
android:layout_height="wrap_content"
16+
android:layout_alignParentTop="true"
17+
android:visibility="gone"
18+
tools:visibility="visible" />
19+
1220
<androidx.recyclerview.widget.RecyclerView
1321
android:id="@+id/list"
1422
android:layout_width="match_parent"
15-
android:layout_height="match_parent"
23+
android:layout_height="0dp"
24+
android:layout_below="@id/iterable_inbox_toolbar"
25+
android:layout_alignParentBottom="true"
1626
app:layoutManager="LinearLayoutManager"
1727
tools:listitem="@layout/iterable_inbox_item" />
1828

0 commit comments

Comments
 (0)