Skip to content

Commit 2e128ae

Browse files
[SDK-338] Inbox Header Customization (#1054)
1 parent 829bd6c commit 2e128ae

15 files changed

Lines changed: 705 additions & 30 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ All notable changes to this project will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [Unreleased]
6+
### Added
67
- Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`.
8+
- New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`:
9+
- `None` (default) — no toolbar; behavior is unchanged from prior SDK versions.
10+
- `Default` — title-only toolbar above the inbox list.
11+
- `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`.
12+
- `Custom(layoutRes)` — inflates the integrator's own toolbar layout. Views tagged with the reserved ids `@id/iterable_reserved_inbox_toolbar_action` and `@id/iterable_reserved_inbox_toolbar_title` are auto-wired to the SDK's back handler and title binding respectively. Both ids are optional.
13+
- Configure programmatically via `IterableInboxFragment.newInstance(...)` (new 2-arg and 6-arg overloads) or via `IterableInboxActivity` intent extras (`TOOLBAR_OPTION` / `TOOLBAR_TITLE`).
14+
- Requires the host activity to use a `Theme.AppCompat` descendant when the toolbar is enabled.
715

816
## [3.8.0]
917
### Added
@@ -23,6 +31,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2331
### Migration guide
2432
**No action required for most apps.** The default `FORCE_EDGE_TO_EDGE` preserves the existing behavior where in-app content draws behind system bars.
2533

34+
The inflated root of `IterableInboxFragment` is now a `LinearLayout` instead of a `RelativeLayout`. Subclasses that override `onCreateView` and cast `super.onCreateView(...)`'s return value to `RelativeLayout` should change the cast to `ViewGroup` (or `LinearLayout`).
35+
2636
If the close button in your fullscreen in-app messages is obscured by the status bar, you can fix it by choosing one of these modes:
2737

2838
```java

iterableapi-ui/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ android {
4040
enableAndroidTestCoverage true
4141
}
4242
}
43+
44+
testOptions.unitTests.includeAndroidResources = true
4345
}
4446

4547
dependencies {
@@ -54,6 +56,9 @@ dependencies {
5456
implementation 'com.google.android.material:material:1.12.0'
5557

5658
testImplementation 'junit:junit:4.13.2'
59+
testImplementation 'androidx.test:core:1.6.1'
60+
testImplementation 'androidx.test.ext:junit:1.2.1'
61+
testImplementation 'org.robolectric:robolectric:4.14.1'
5762
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
5863
androidTestImplementation 'androidx.test:runner:1.6.2'
5964
androidTestImplementation 'androidx.test:rules:1.6.1'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.iterable.iterableapi.ui.inbox
2+
3+
import androidx.annotation.LayoutRes
4+
import java.io.Serializable
5+
6+
/**
7+
* Configures the opt-in inbox toolbar. Pass as a fragment argument via
8+
* [IterableInboxFragment.newInstance] or as an intent extra on [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+
private fun readResolve(): Any = None
15+
}
16+
17+
/** A title-only toolbar above the inbox list. */
18+
data object Default : InboxToolbarOption {
19+
private fun readResolve(): Any = Default
20+
}
21+
22+
/** A toolbar with the configured title plus a back navigation icon. */
23+
data object WithBackButton : InboxToolbarOption {
24+
private fun readResolve(): Any = WithBackButton
25+
}
26+
27+
/**
28+
* Inflates a fully custom toolbar layout supplied by the integrator. The integrator
29+
* owns all wiring for their own views (menus, clicks, icons, etc.).
30+
*
31+
* Reserved opt-in ids - the SDK looks up these ids via `findViewById` and, if
32+
* present, auto-wires them. The names are deliberately namespaced; do not reuse
33+
* them on unrelated views in the custom layout. Omitting either id keeps the SDK
34+
* from touching that view.
35+
*
36+
* - `@id/iterable_reserved_inbox_toolbar_action` - auto-wired to the SDK's default
37+
* back handler. Override the action by implementing
38+
* [IterableInboxToolbarBackListener] on the host Activity or parent Fragment.
39+
* - `@id/iterable_reserved_inbox_toolbar_title` - if the view is a `TextView`, the
40+
* SDK sets its text to the `toolbarTitle` argument (or the default "Inbox"
41+
* string when null).
42+
*/
43+
data class Custom(@LayoutRes val layoutRes: Int) : InboxToolbarOption
44+
}

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: 92 additions & 8 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;
@@ -15,7 +17,6 @@
1517
import android.view.LayoutInflater;
1618
import android.view.View;
1719
import android.view.ViewGroup;
18-
import android.widget.RelativeLayout;
1920
import android.widget.TextView;
2021

2122
import com.iterable.iterableapi.InboxSessionManager;
@@ -35,19 +36,29 @@
3536
* The main class for Inbox UI. Renders the list of Inbox messages and handles touch interaction:
3637
* tap on an item opens the in-app message, swipe left deletes it.
3738
* <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)},
39+
* To customize the UI, create the fragment with one of the {@code newInstance(...)} overloads
40+
* (use {@link InboxToolbarOption} to opt into the built-in toolbar), or subclass
41+
* {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)},
4042
* {@link #setComparator(IterableInboxComparator)} and {@link #setFilter(IterableInboxFilter)}.
43+
* Implement {@link IterableInboxToolbarBackListener} on the host to handle toolbar back clicks.
44+
* <p>
45+
* The host activity must use a {@code Theme.AppCompat} descendant when the opt-in
46+
* toolbar is enabled.
4147
*/
4248
public class IterableInboxFragment extends Fragment implements IterableInAppManager.Listener, IterableInboxAdapter.OnListInteractionListener {
4349
private static final String TAG = "IterableInboxFragment";
4450
public static final String INBOX_MODE = "inboxMode";
4551
public static final String ITEM_LAYOUT_ID = "itemLayoutId";
52+
public static final String TOOLBAR_OPTION = "toolbarOption";
53+
public static final String TOOLBAR_TITLE = "toolbarTitle";
4654

4755
private InboxMode inboxMode = InboxMode.POPUP;
4856
private @LayoutRes int itemLayoutId = R.layout.iterable_inbox_item;
4957
private String noMessagesTitle;
5058
private String noMessagesBody;
59+
private InboxToolbarOption toolbarOption = InboxToolbarOption.None.INSTANCE;
60+
private @Nullable String toolbarTitle;
61+
private @Nullable IterableInboxToolbarBackListener toolbarBackListener;
5162
TextView noMessagesTitleTextView;
5263
TextView noMessagesBodyTextView;
5364
RecyclerView recyclerView;
@@ -93,6 +104,41 @@ public class IterableInboxFragment extends Fragment implements IterableInAppMana
93104
return inboxFragment;
94105
}
95106

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

196+
@Override
197+
public void onAttach(@NonNull Context context) {
198+
super.onAttach(context);
199+
Fragment parent = getParentFragment();
200+
if (parent instanceof IterableInboxToolbarBackListener) {
201+
toolbarBackListener = (IterableInboxToolbarBackListener) parent;
202+
} else if (context instanceof IterableInboxToolbarBackListener) {
203+
toolbarBackListener = (IterableInboxToolbarBackListener) context;
204+
}
205+
}
206+
207+
@Override
208+
public void onDetach() {
209+
toolbarBackListener = null;
210+
super.onDetach();
211+
}
212+
150213
@Override
151214
public void onCreate(@Nullable Bundle savedInstanceState) {
152215
super.onCreate(savedInstanceState);
@@ -171,20 +234,41 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
171234
if (arguments.getString(IterableConstants.NO_MESSAGES_BODY) != null) {
172235
noMessagesBody = arguments.getString(IterableConstants.NO_MESSAGES_BODY);
173236
}
237+
InboxToolbarOption toolbarOptionArg = BundleCompat.getSerializable(arguments, TOOLBAR_OPTION, InboxToolbarOption.class);
238+
if (toolbarOptionArg != null) {
239+
toolbarOption = toolbarOptionArg;
240+
}
241+
if (arguments.getString(TOOLBAR_TITLE) != null) {
242+
toolbarTitle = arguments.getString(TOOLBAR_TITLE);
243+
}
244+
}
245+
246+
ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.iterable_inbox_fragment, container, false);
247+
248+
IterableInboxToolbarView toolbar = rootView.findViewById(R.id.iterable_inbox_toolbar);
249+
toolbar.apply(toolbarOption, toolbarTitle);
250+
// Prefer the host listener if one was discovered in onAttach; otherwise delegate
251+
// to the fragment's host activity so we never depend on the view's Context chain
252+
// to find a ComponentActivity.
253+
if (toolbarBackListener != null) {
254+
toolbar.setOnBackClickListener(v -> toolbarBackListener.onInboxToolbarBackClick());
255+
} else {
256+
toolbar.setOnBackClickListener(v ->
257+
requireActivity().getOnBackPressedDispatcher().onBackPressed()
258+
);
174259
}
175260

176-
RelativeLayout relativeLayout = (RelativeLayout) inflater.inflate(R.layout.iterable_inbox_fragment, container, false);
177-
recyclerView = relativeLayout.findViewById(R.id.list);
261+
recyclerView = rootView.findViewById(R.id.list);
178262
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
179263
IterableInboxAdapter adapter = new IterableInboxAdapter(IterableApi.getInstance().getInAppManager().getInboxMessages(), IterableInboxFragment.this, adapterExtension, comparator, filter, dateMapper);
180264
recyclerView.setAdapter(adapter);
181-
noMessagesTitleTextView = relativeLayout.findViewById(R.id.emptyInboxTitle);
182-
noMessagesBodyTextView = relativeLayout.findViewById(R.id.emptyInboxMessage);
265+
noMessagesTitleTextView = rootView.findViewById(R.id.emptyInboxTitle);
266+
noMessagesBodyTextView = rootView.findViewById(R.id.emptyInboxMessage);
183267
noMessagesTitleTextView.setText(noMessagesTitle);
184268
noMessagesBodyTextView.setText(noMessagesBody);
185269
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new IterableInboxTouchHelper(getContext(), adapter));
186270
itemTouchHelper.attachToRecyclerView(recyclerView);
187-
return relativeLayout;
271+
return rootView;
188272
}
189273

190274
@Override
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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_reserved_inbox_toolbar_action}. If no host implements this
10+
* interface, the fragment falls back to the host activity's
11+
* {@code OnBackPressedDispatcher}.
12+
*
13+
* <p>The listener is discovered during {@code onAttach()}, so it survives process
14+
* death - recreated fragments re-bind to the restored host automatically.
15+
*/
16+
public interface IterableInboxToolbarBackListener {
17+
void onInboxToolbarBackClick();
18+
}

0 commit comments

Comments
 (0)