Chapter 3. Notifications appearance
Intro
In Chapter 2 we provided a robust mechanism for queueing notifications. The system is able to persist notifications and fetch it with a given interval. This chapter is focused on showing notifications to a user.
Appearance component in high-level design
Notification types
There are two types of notifications in the app. We named it Fullscreen
and System
. All notifications are sharing some configuration like:
- Small icon
- Priority
- Category
- Layout
Other properties that should be configured independently are declared in NotificationData
class.
System
It is a regular notification in the Android system. We only use Notification.Builder() API for setting it up and showing.
Fullscreen
Notification based on new activity. It has a fully custom UI, fully custom gesture detection for swipe and click, fully custom animation for hiding and so on. Generally it’s used when notifications demands the user’s immediate attention.
Prior to API29 we could just send an Intent for opening a particular activity. But API29 brought new restrictions. So after API29 we must use setFullscreenIntent() of Notification.Builder(). Behaviour is differs but I’m considering result as a satisfying.
Implementation
System
Declare the simple interface that will convert somehow NotificationData
to android.app.Notification
:
// :notifications interface SystemNotificationBuilder { fun buildSystemNotification(data: NotificationData): Notification }
I intentionally choose an API with returning type and move call NotificationManager.notify()
outside of this abstraction. In future, it’ll be beneficial for Fullscreen
notifications implementation.
After API26 we have to create NotificationChannel before showing notifications. So we need two(at least for now) implementations of the builder — one for API26 and one for older Android versions.
Let’s start with the second one:
// :notifications class VintageSystemNotificationBuilder( private val context: Context ) : SystemNotificationBuilder { override fun buildSystemNotification(data: NotificationData): Notification { val remoteViews = setupRemoteViews(data) // <1> return NotificationCompat.Builder(context, data.channel.toString()) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setContentText(data.description) .setContentTitle(data.title) .setPriority(PRIORITY_MAX) .setCategory(CATEGORY_ALARM) .setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE) .setCustomContentView(remoteViews) .build() } }
VintageSystemNotificationBuilder
is a core of all notifications in the framework. It keeps the most common configuration for notifications and sharing it across all use cases. Also here (1) is settled up custom view for notification (remembering from Chapter 1 — we should have gradient background there).
// :notifications @RequiresApi(Build.VERSION_CODES.O) class Api26SystemNotificationBuilder( private val context: Context, private val delegate: SystemNotificationBuilder, // <1> private val notificationManager: NotificationManager ) : SystemNotificationBuilder { override fun buildSystemNotification(data: NotificationData): Notification { val channel = createChannel(data.channel) notificationManager.createNotificationChannel(channel) return delegate.buildSystemNotification(data) // <2> } private fun createChannel(channel: NotificationData.Channel): NotificationChannel = NotificationChannel( channel.toString(), context.getString(channel.displayNameRes), channel.importance ) }
This implementation is targeting to API26 and creating NotificationChannel
first and then delegating notification creation to VintageSystemNotificationBuilder
- as a
VintageSystemNotificationBuilder
- Delegating the creation
Now it is ready to be used. We should inject correct implementation to NotificationPoller
. I’m using a factory for it:
// :notifications class SystemNotificationBuilderFactory @Inject constructor( private val context: Context, private val notificationManager: NotificationManager ) { fun create(): SystemNotificationBuilder { val vintageSystemNotificationManager = VintageSystemNotificationBuilder(context) return if (Build.VERSION.SDK_INT >= 26) Api26SystemNotificationBuilder( context, vintageSystemNotificationManager, notificationManager ) else vintageSystemNotificationManager } }
In DI module (Dagger2 in snippet) you should provide SystemNotificationBuilder
using this factory:
// :notifications @Provides @Singleton fun systemNotificationBuilder(factory: SystemNotificationBuilderFactory): SystemNotificationBuilder = factory.create()
Fullscreen
// :notifications interface FullscreenNotificationManager { fun showFullscreenNotification(data: NotificationData) }
Job Offers
It’s named Manager and the API is different from SystemNotificationBuilder
— it doesn’t return android.Notification
because it’s showing should happen inside.
Let’s start with a vintage:
// :notifications class VintageFullscreenNotificationManager( private val context: Context ) : FullscreenNotificationManager { override fun showFullscreenNotification(data: NotificationData) { val intent = context .fullscreenIntent(data) // <1> .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } // <2> context.startActivity(intent) } }
- Simple extension on
Context
for creating explicit intent to openFullscreenNotificationActivity
(will described further) - Calling
startActivity()
from outside of an Activity context requires theFLAG_ACTIVITY_NEW_TASK
flag
// :notifications @RequiresApi(Build.VERSION_CODES.Q) class Api29FullscreenNotificationManager( private val systemNotificationBuilder: SystemNotificationBuilder, // <1> private val notificationManager: NotificationManager, private val context: Context ) : FullscreenNotificationManager { override fun showFullscreenNotification(data: NotificationData) { val intent = context.fullscreenPendingIntent(data) { flags = Intent.FLAG_ACTIVITY_NEW_TASK } // <2> val systemNotification = systemNotificationBuilder.buildSystemNotification(data) // <3> val notification = NotificationCompat.Builder(context, systemNotification) .setFullScreenIntent(intent, true) notificationManager.notify(data.id, notification) } }
- as a
API26SystemNotificationBuilder
- Simple extension on
Context
for creatingPendingIntent
to openFullscreenNotificationActivity
(will be described further) - Delegating the creation of system notifications for getting commonly configured notification
Using the same factory approach:
// :notifications class FullscreenNotificationManagerFactory @Inject constructor( private val context: Context, private val notificationManager: NotificationManager, private val systemNotificationBuilder: SystemNotificationBuilder, ) { fun create(): FullscreenNotificationManager = if (Build.VERSION.SDK_INT >= 29) Api29FullscreenNotificationManager( systemNotificationBuilder, notificationManager, context ) else VintageFullscreenNotificationManager(context) }
Fullscreen Activity
Your project will have its own activity implementation, purpose of this snippet is help with basic scenarios you’ll need to handle.
// :notifications class FullscreenNotificationActivity : Activity() { private lateinit var notificationData: NotificationData private lateinit var binding: ActivityNotificationFullscreenBinding @Inject lateinit var notificationManager : NotificationManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityNotificationFullscreenBinding.inflate(layoutInflater) setContentView(binding.root) notificationData = intent.getSerializableExtra(EXTRA_NOTIFICATION_DATA) as NotificationData // <1> setupWindow() applyData(notificationData) binding.notification.setOnClickListener { swipeNotification(-screenWidth) { performNotificationAction() } } } } @SuppressLint("ClickableViewAccessibility") private fun setupWindow() { // <2> window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) window.statusBarColor = Color.TRANSPARENT val swipeThreshold = 500 var swipeStartX = 0f var swipeDistance = 0f var viewStartX = 0f window.decorView.rootView.setOnTouchListener { _, motionEvent -> val notificationView = binding.notification.root val idleX = notificationView.marginStart return@setOnTouchListener when (motionEvent.action) { MotionEvent.ACTION_DOWN -> { swipeStartX = motionEvent.rawX viewStartX = notificationView.x true } MotionEvent.ACTION_MOVE -> { swipeDistance = motionEvent.rawX - swipeStartX notificationView.x = viewStartX + swipeDistance true } MotionEvent.ACTION_UP -> { val stickX = if (swipeDistance.absoluteValue < swipeThreshold) idleX else if (swipeDistance > 0) screenWidth else -screenWidth swipeNotification(stickX) { if (stickX != idleX) { finishAndCancelNotification() } } true } else -> super.onTouchEvent(motionEvent) } } } private fun swipeNotification(stickX: Int, onEndAction: () -> Unit) { // <3> binding.notification.root.animate() .x(stickX.toFloat()) .setInterpolator(AccelerateDecelerateInterpolator()) .withEndAction(onEndAction) .start() } private fun applyData(data: NotificationData) { // <4> val gradient = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf(data.backgroundGradientStart, data.backgroundGradientEnd) ) binding.notification.apply { title.text = data.title title.setTextColor(data.textColor) description.text = data.description description.setTextColor(data.textColor) root.background = gradient when (val iconData = data.icon) { is NotificationData.Icon.Res -> { icon.setImageResource(iconData.resId) if (iconData.tintRes != 0) { icon.imageTintList = ColorStateList.valueOf(getColor(iconData.tintRes)) } } is NotificationData.Icon.Url -> { icon.load(iconData.url) } } } } private fun performNotificationAction() { // <5> val contentIntent = contentIntent(notificationData) startActivity(contentIntent) finishAndCancelNotification() } private fun finishAndCancelNotification() { // <6> notificationManager.cancel(notificationData.id) finish() } }
- Extracting
NotificationData
from intent - Setting up
Window
. We need a fully transparent window, so it is important to make the status bar transparent. Also here is settled up gestures handles - Swipe notification body out of screen with end action.
- Applying
NotificationData
to the UI - Perform notification action on click on notification body. It is simple starting the main (and single) activity with forwarding
NotificationData
as an serializable extra - As
Fullscreen
notification is fully custom handled at API < 26 we shouldn’t forget to letNotificationManager
know that notification is canceled
Another important part is configuring FullscreenNotificationActivity
in AndroidManifest.xml
// :notifications <activity android:name=".fullscreen.FullscreenNotificationActivity" android:excludeFromRecents="true" android:exported="false" android:launchMode="singleTask" android:theme="@android:style/Theme.Translucent.NoTitleBar" />
Usage
Now when implementation is done, we are ready to use it in NotificationPoller.showNotifications()
method:
// NotificationPoller private fun showNotification(data: NotificationData) { when (data.type) { NotificationData.Type.FULLSCREEN -> fullscreenNotificationManager.showFullscreenNotification(data) NotificationData.Type.SYSTEM -> { val notification = systemNotificationBuilder.buildSystemNotification(data) notificationManager.notify(data.id, notification) } } }
Conclusion
This chapter covers implementations for showing notifications. Framework having a core of notification appearance declared in a single place — VintageSystemNotificationBuilder
and using this consistently across all the implementations for System
and Fullscreen
notifications. FullscreenNotificationActivity
can be configured as you need, but the core use cases like gesture detection for swipes, canceling notification in NotificationManager
, performing notification action, and manifest configuration can be used as is.
The first 3 chapters are covering the infrastructure for the notifications framework: polling, persistence, and appearance. The final chapter will cover notification sources as well as the core of client-based notifications — scheduling.
This article was originally published on proandroiddev.com