How to Build an Android Image Feed Application
By the end of this tutorial, you will have built your own social app capable of algorithmically ranking image posts in an aggregated feed from your community.
Join the DZone community and get the full member experience.
Join For FreeThis tutorial will discuss how to build an Android image feed application using Amity Social Cloud. By the end of this tutorial, you will have built your own social app capable of algorithmically ranking image posts in an aggregated feed from your community.
We’ll start with prerequisites for creating a new network in Amity Portal and a project in Android Studio. Then we’ll discuss how to create the Gradle setup and initialize the Social SDK. After this, we’ll go through the implementation, and finally, we'll code the components and build out the screens.
As a result, you'll be able to transform your Android application into a powerful social product and engage your users with personalized feeds aggregating image posts from your community.
Prerequisites
Network Setup
- If you don't yet have an Amity account, please view the step-by-step guide on how to create a new network in Amity Portal.
Tools and IDE
You can download Android Studio 3.6 from the Android Studio page.
Android Studio provides a complete IDE, including an advanced code editor and app templates. It also contains tools for development, debugging, testing, and performance that makes it faster and easier to develop apps. You can use Android Studio to test your apps with a large range of preconfigured emulators, or on your own mobile device. You can also build production apps and publish apps on the Google Play store.
Android Studio is available for computers running Windows or Linux, and for Macs running macOS. The OpenJDK (Java Development Kit) is bundled with Android Studio.
Environment Setup
Create a New Android App
First of all, we will need to create an application in Android Studio.
1. Open Android Studio.
2. In the Welcome to Android Studio dialog, click Start a new Android Studio project.
3. Select the "No Activity" option and click Next.
4. Name your application and package name and select the minimum SDK as API 21.
Gradle Setup
There are a few key dependencies that we need to include in the application. The image feed app needs abilities to interact with ASC SDK and render images. To render images, we pick the Glide library. It is a simple and powerful image rendering library.
Project's settings.gradle:
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "Amity Image Feed"
include ':app'
Application's build.gradle:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
android {
compileSdk 32
defaultConfig {
applicationId "com.amity.imagefeed"
minSdk 21
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
packagingOptions {
exclude 'META-INF/INDEX.LIST'
exclude 'META-INF/io.netty.versions.properties'
}
buildFeatures {
viewBinding true
dataBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.4.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.4.2'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-sdk:5.16.0'
implementation 'com.github.bumptech.glide:glide:4.13.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0'
}
Social SDK Initialization
Before using the Social SDK, you will need to create a new SDK instance with your API key. Please find your account API key in Amity Social Cloud Console.
After logging in Console:
- Click Settings to expand the menu.
- Select Security.
- On the Security page, you can find the API key in the Keys section.
We currently support multi-data center capabilities for the following regions:
Then attach the API key and AmityEndpoint
in the newly created application class. Once you created the application class, don't forget to declare it in AndroidManifest.xml as well.
class ImageFeedApp: Application() {
override fun onCreate() {
super.onCreate()
AmityCoreClient.setup("apiKey", AmityEndpoint.SG)
}
}
Implementation Overview
Screens
First of all, what we're developing is an image feed application that would contain a few essential pages:
- Login screen - The page where the user identifies themselves
- Image feed screen - The main page that shows the image feed
- Image post-creation screen - The page where the user can upload a new image post to the feed
- Comment screen - he page where the user can add, edit, remove and view comments in an image post.
Architecture
As Google recommended - each application should have at least two layers:
- The UI layer that displays application data on the screen
- The data layer that contains the business logic of your app and exposes application data
You can add an additional layer called the domain layer to simplify and reuse the interactions between the UI and data layers. In this tutorial, we'd like to adopt the principle and build our application by using MVVM architecture (as described in the image below).
Presentation is basically an Activity, Fragment, or View which is responsible for displaying UI (User Interface) and giving UX (User Experience) of users. This component will interact with the View Model component to request, send, and save data or state.
ViewModel is fully responsible for communicating between the presentation layer and the data layer and also representing the state, structure, and behavior of the model in the data layer.
Data source is responsible for implementing business logic and holding data or state in the app (in this case, Amity Social SDK).
Navigation Graph
We now know that the essential screens are the login, image feed, post creation, and comment screens. Let's start by creating four blank fragments and build a navigation graph for them. The navigation should begin with the login screen and then move on to the image feed screen. The image feed screen should also have links to the comment list and the post-creation screens.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/LoginFragment">
<fragment
android:id="@+id/LoginFragment"
android:name="com.amity.imagefeed.fragment.LoginFragment"
android:label="Login Fragment">
<action
android:id="@+id/action_LoginFragment_to_ImageFeedFragment"
app:destination="@id/ImageFeedFragment" ></action>
</fragment>
<fragment
android:id="@+id/ImageFeedFragment"
android:name="com.amity.imagefeed.fragment.ImageFeedFragment"
android:label="Image Feed Fragment">
<action
android:id="@+id/action_ImageFeedFragment_to_LoginFragment"
app:destination="@id/LoginFragment" ></action>
<action
android:id="@+id/action_ImageFeedFragment_to_CreatePostFragment"
app:destination="@id/CreatePostFragment" ></action>
<action
android:id="@+id/action_ImageFeedFragment_to_CommentListFragment"
app:destination="@id/CommentListFragment" ></action>
</fragment>
<fragment
android:id="@+id/CreatePostFragment"
android:name="com.amity.imagefeed.fragment.CreatePostFragment"
android:label="Create Post Fragment">
<action
android:id="@+id/action_CreatePostFragment_to_ImageFeedFragment"
app:destination="@id/ImageFeedFragment" ></action>
</fragment>
<fragment
android:id="@+id/CommentListFragment"
android:name="com.amity.imagefeed.fragment.CommentListFragment"
android:label="Create Post Fragment">
<action
android:id="@+id/action_CreatePostFragment_to_ImageFeedFragment"
app:destination="@id/ImageFeedFragment" ></action>
</fragment>
</navigation>
Login Screen
In the past few sections, we already prepared everything for the app development. Now, it's time to start implementing the application.
Create a Login Screen Layout
To build the login screen, the most important part is definitely a layout. Let's build a simple login screen with a userId edit text input, a display name edit text input, and a login button by the following XML layout.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activity.LoginActivity">
<LinearLayout
android:layout_centerVertical="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/user_id_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:hint="Enter user id" ></EditText>
<EditText
android:id="@+id/display_name_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:hint="Enter Display Name" ></EditText>
<Button
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="128dp"
android:text="Login" ></Button>
</LinearLayout>
</RelativeLayout>
Create a Login View Model
Before we create an activity, we'd like to separate business logic into a view model, as we mentioned earlier. However, since Amity Social SDK already handles the complexity on the data layer, we won't need to create a data layer in the application at all.
The expected function inside a login view model would only be a login()
function. We need a user to authenticate to the Amity Social Cloud system by using AmityCoreClient.login(userId)
.
class LoginViewModel : ViewModel() {
fun login(
userId: String,
displayName: String,
onLoginSuccess: () -> Unit,
onLoginError: (throwable: Throwable) -> Unit
): Completable {
return AmityCoreClient.login(userId)
.displayName(displayName)
.build()
.submit()
.subscribeOn(Schedulers.io())
.doOnComplete { onLoginSuccess.invoke() }
.doOnError { onLoginError.invoke(it) }
}
}
Create a Login Fragment
Whoo! Ok, now we're ready to connect everything we built into a fragment. When the user presses the login button, a fragment will interact with a view model to log in to the Amity Social Cloud system. The required fields will be retrieved from edit text inputs, which are userId
and displayName
. If the user is logged in properly, it will navigate to the main image feed screen. If an error occurs, it will show the toast that indicates the error.
class LoginFragment : Fragment() {
private val viewModel: LoginViewModel by viewModels()
private var binding: FragmentLoginBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.loginButton?.setOnClickListener {
val userId = binding?.userIdEditText?.text.toString()
val displayName = binding?.displayNameEditText?.text.toString()
viewModel.login(
userId = userId,
displayName = displayName,
onLoginSuccess = { navigateToImageFeedPage() },
onLoginError = { presentErrorDialog(it) }
)
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
}
}
private fun navigateToImageFeedPage() {
findNavController().navigate(R.id.action_LoginFragment_to_ImageFeedFragment)
}
private fun presentErrorDialog(throwable: Throwable) {
Toast.makeText(context, throwable.message, Toast.LENGTH_SHORT).show()
}
}
Lastly, we need an activity as a container for fragments. We're going to create an empty activity that specifies our created navigation graph as navGraph
.
Layout:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_image_feed" ></fragment>
</androidx.constraintlayout.widget.ConstraintLayout>
Activity class:
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
Before trying to compile code for the first time, don't also forget to declare MainActivity
as a launcher activity as well.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.amity.imagefeed">
<application
android:name=".ImageFeedApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AmityImageFeed.NoActionBar">
<activity
android:name=".activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" ></action>
<category android:name="android.intent.category.LAUNCHER" ></category>
</intent-filter>
</activity>
</application>
</manifest>
Yay! we finally successfully created a simple login screen. Let's continue building the image feed screen and image post-creation screen in the next part!
Image Feed Screen
This screen is a little more complicated and also has a number of different components on it. So, before we start implementing it, let's break it down as much as possible.
- Image feed main container - The page that combines all components together (
ImageFeedFragment
) - Image feed
ViewModel
- TheViewModel
class that stores and manages UI-related data in a lifecycle (ImageFeedViewModel
) - List of image posts - The list that shows a collection of image posts on the feed (
ImageFeedAdapter
) - Empty state - The view when there are no posts on the feed.
- Floating action button - The button that allows the user to create a new post.
- Progress bar - The view when the feed is loading.
In the image feed screen, we can now see all of the necessary components. It's time to put them into action and combine them together.
Create an Image Post Item Layout
We're taking a bottom-up approach to creating the image feed screen, which means we'll start with a RecyclerView
holder layout first and an image feed screen afterward. There would be three main elements of the image post-item arrangement. First, there's the header, which includes the poster's avatar and display name, as well as the time it was posted. The posted image is the second section, followed by the number of comments and reactions. Phew. Let's see what the layout XML would look like.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/avatar_image_view"
style="@style/AmityCircularAvatarStyle"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginStart="16dp"
android:background="@color/light_grey"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" ></com>
<LinearLayout
android:id="@+id/layout_posted_by"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginBottom="12dp"
app:layout_constraintStart_toEndOf="@id/avatar_image_view"
app:layout_constraintTop_toTopOf="@id/avatar_image_view">
<TextView
android:id="@+id/display_name_text_view"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="2"
android:textAlignment="textStart"
android:textColor="@color/primary_grey"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
tools:text="Brian Marty" ></TextView>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintLeft_toRightOf="@id/avatar_image_view"
app:layout_constraintTop_toBottomOf="@id/layout_posted_by">
<TextView
android:id="@+id/post_time_text_view"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Posted on 8:34 PM, 25 March 2023" ></TextView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<ImageView
android:id="@+id/item_gallery_post_image_imageview"
android:layout_width="0dp"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:background="@color/light_grey"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" ></ImageView>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<LinearLayout
android:id="@+id/reaction_status_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:ignore="MissingConstraints">
<TextView
android:id="@+id/description_textview"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:textColor="@color/primary_grey"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Brian Marty : my favourite place on earth" ></TextView>
</LinearLayout>
<LinearLayout
android:id="@+id/post_action_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginStart="12dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/reaction_status_view">
<TextView
android:padding="8dp"
android:id="@+id/like_count_textview"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="12dp"
android:gravity="center_vertical"
android:textColor="@color/primary_grey"
app:drawableStartCompat="@drawable/ic_like_reaction"
tools:text="12 likes" ></TextView>
<TextView
android:id="@+id/comment_count_textview"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:drawablePadding="12dp"
android:padding="8dp"
android:gravity="center_vertical"
android:textColor="@color/primary_grey"
app:drawableStartCompat="@drawable/ic_comment"
tools:text="86 comments" ></TextView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
Create an Image Feed Screen Layout
For the image feed, we created a RecyclerView
holder. Now we just need a RecyclerView
and a container for it. As earlier said, we'll have a page that displays a list of image posts, an empty state view when there are no posts, and a floating action button for creating new posts.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:paddingTop="56dp"
android:id="@+id/feed_recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent" ></androidx>
<LinearLayout
android:visibility="gone"
android:id="@+id/empty_feed_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:src="@drawable/ic_ballon" ></ImageView>
<TextView
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/amity_empty_feed"
android:textColor="@color/black"
android:textStyle="bold" ></TextView>
<TextView
android:id="@+id/tvEmptyGlobalFeed"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/amity_empty_feed_description"
android:textColor="@color/black" ></TextView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabCreatePost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="24dp"
android:contentDescription="@string/amity_add_post"
app:srcCompat="@drawable/ic_add_post" ></com>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_centerInParent="true"
android:visibility="visible"
></com>
</RelativeLayout>
Create an Image Feed ViewModel
The ViewModel
would be simple and straightforward. In this view model, there is only one function: getFeed()
. The function is in charge of getting posts via AmitySocialClient
and passing PagingData<AmityPost>
to the presentation layer.
@ExperimentalPagingApi
class ImageFeedViewModel : ViewModel() {
fun getFeed(onFeedUpdated: (postPagingData: PagingData<AmityPost>) -> Unit): Completable {
return AmitySocialClient.newFeedRepository()
.getGlobalFeed()
.build()
.getPagingData()
.doOnNext { onFeedUpdated.invoke(it) }
.ignoreElements()
.subscribeOn(Schedulers.io())
}
}
Create an Image Feed Adapter
We now know that the data return PagingData<AmityPost>
model for us to render in the image post item after we created a ViewModel
. To properly construct a RecyclerView
, two components are required for creating an image feed adapter: PagingAdapter
and ViewHolder
. Let's construct both classes now that the layout XML has been prepared as well.
ViewHolder
:
class ImagePostViewHolder(private val binding: ListItemPostBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(post: AmityPost?) {
presentHeader(post)
presentContent(post)
presentFooter(post)
}
private fun presentHeader(post: AmityPost?) {
//render poster's avatar
Glide.with(itemView)
.load(post?.getPostedUser()?.getAvatar()?.getUrl(AmityImage.Size.SMALL))
.transition(DrawableTransitionOptions.withCrossFade())
.into(binding.avatarImageView)
//render poster's display name
binding.displayNameTextView.text = post?.getPostedUser()?.getDisplayName() ?: "Unknown user"
//render posted time
binding.postTimeTextView.text =
post?.getCreatedAt()?.millis?.readableFeedPostTime(itemView.context) ?: ""
}
private fun presentContent(post: AmityPost?) {
//render image post
//clear image cache from the view first
binding.itemGalleryPostImageImageview.setImageDrawable(null)
//make sure that the post contains children posts
if (post?.getChildren()?.isNotEmpty() == true) {
val childPost = post.getChildren()[0]
//make sure that the child post is an image post
if (childPost.getData() is AmityPost.Data.IMAGE
&& (childPost.getData() as AmityPost.Data.IMAGE).getImage() != null
) {
val image = (childPost.getData() as AmityPost.Data.IMAGE).getImage()
Glide.with(itemView)
.load(image?.getUrl(AmityImage.Size.LARGE))
.transition(DrawableTransitionOptions.withCrossFade())
.into(binding.itemGalleryPostImageImageview)
}
}
//render image post description
val postDescription = (post?.getData() as? AmityPost.Data.TEXT)?.getText() ?: ""
val displayName = post?.getPostedUser()?.getDisplayName() ?: "Unknown user"
binding.descriptionTextview.text = "$displayName : $postDescription"
}
private fun presentFooter(post: AmityPost?) {
//render like count
binding.likeCountTextview.text = getLikeCountString(post?.getReactionCount() ?: 0)
//render comment count
binding.commentCountTextview.text = getCommentCountString(post?.getCommentCount() ?: 0)
}
private fun getLikeCountString(likeCount: Int): String {
return itemView.context.resources.getQuantityString(
R.plurals.amity_number_of_likes,
likeCount,
likeCount
)
}
private fun getCommentCountString(reactionCount: Int): String {
return itemView.context.resources.getQuantityString(
R.plurals.amity_number_of_comments,
reactionCount,
reactionCount
)
}
}
PagingAdapter
:
class ImagePostAdapter :
PagingDataAdapter<AmityPost, ImagePostViewHolder>(ImagePostDiffCallback()) {
override fun onBindViewHolder(holder: ImagePostViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImagePostViewHolder {
return ImagePostViewHolder(
ListItemPostBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
}
private class ImagePostDiffCallback : DiffUtil.ItemCallback<AmityPost>() {
override fun areItemsTheSame(oldItem: AmityPost, newItem: AmityPost): Boolean {
return oldItem.getPostId() == newItem.getPostId()
}
override fun areContentsTheSame(oldItem: AmityPost, newItem: AmityPost): Boolean {
return oldItem.getPostId() == newItem.getPostId()
&& oldItem.getUpdatedAt() == newItem.getUpdatedAt()
&& oldItem.getReactionCount() == newItem.getReactionCount()
}
}
After we created the simple ViewHolder
and adapter, let's add a few more functionalities to the footer part. The footer should be able to add or remove a like reaction when clicking the like button, as well as the comment button, and it should navigate the user to the comment list screen as well. The like button will only be highlighted if the current user already reacted to the post.
private fun presentFooter(post: AmityPost?) {
//render like count
binding.likeCountTextview.text = getLikeCountString(post?.getReactionCount() ?: 0)
//render comment count
binding.commentCountTextview.text = getCommentCountString(post?.getCommentCount() ?: 0)
val isLikedByMe = post?.getMyReactions()?.contains("like") == true
val context = binding.root.context
val highlightedColor = ContextCompat.getColor(context, R.color.teal_700)
val inactiveColor = ContextCompat.getColor(context, R.color.dark_grey)
if (isLikedByMe) {
//present highlighted color if the post is liked by me
setLikeTextViewDrawableColor(highlightedColor)
} else {
//present inactive color if the post isn't liked by me
setLikeTextViewDrawableColor(inactiveColor)
}
//add or remove a like reaction when clicking like textview
binding.likeCountTextview.setOnClickListener {
if (isLikedByMe) {
post?.react()?.removeReaction("like")?.subscribe()
} else {
post?.react()?.addReaction("like")?.subscribe()
}
}
//navigate to comment list screen when clicking comment textview
binding.commentCountTextview.setOnClickListener {
Navigation.findNavController(binding.root).navigate(R.id.action_ImageFeedFragment_to_CommentListFragment)
}
}
private fun setLikeTextViewDrawableColor(@ColorInt color: Int) {
for (drawable in binding.likeCountTextview.compoundDrawablesRelative) {
if (drawable != null) {
drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
}
binding.likeCountTextview.setTextColor(color)
}
Create an Image Feed Fragment
Cool! Now it's time to put everything we've worked on together. This is the most intriguing aspect of the screen. Layouts, an adaptor, and a ViewModel
have all been prepared. All of this will be combined into a fragment. The fragment will be in charge of interacting with the ViewModel
and updating the view states. We need additionally deal with the loading, loading, and empty states of the feed. So, here it is!
@ExperimentalPagingApi
class ImageFeedFragment : Fragment() {
private val viewModel: ImageFeedViewModel by viewModels()
private lateinit var adapter: ImagePostAdapter
private var binding: FragmentImageFeedBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentImageFeedBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
getFeedData()
}
private fun setupRecyclerView() {
adapter = ImagePostAdapter()
binding?.feedRecyclerview?.layoutManager = LinearLayoutManager(context)
binding?.feedRecyclerview?.adapter = adapter
adapter.addLoadStateListener { loadStates ->
when (loadStates.mediator?.refresh) {
is LoadState.NotLoading -> {
handleEmptyState(adapter.itemCount)
}
}
}
}
private fun getFeedData() {
viewModel.getFeed {
lifecycleScope.launch {
adapter.submitData(it)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
}
private fun handleEmptyState(itemCount: Int) {
binding?.progressBar?.visibility = View.GONE
binding?.emptyFeedView?.visibility = if (itemCount > 0) View.GONE else View.VISIBLE
}
}
Brilliant!! Let's try to run the app and see how it goes.
Image Post Creation Screen
Now that we can see pre-created image posts on a feed, it's the moment to create a new post! Of course, we will consistently use the same structure of this screen as the image feed screen: a fragment, ViewModel
and XML layout.
Create an Image Post Creation Layout
This screen's functionality should be straightforward: an EditTextView
for the post description, a TextView
for the image attachment button, an ImageView
for the selected image preview, and finally, a button to create the post.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="100dp">
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/create_post_edittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:layout_marginTop="64dp"
android:hint="@string/amity_post_create_hint"
app:layout_constraintTop_toTopOf="parent" ></androidx>
<TextView
android:id="@+id/attach_image_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/amity_attach_image"
android:textColor="@color/purple_500"
android:textSize="16dp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/create_post_edittext" ></TextView>
<ImageView
android:visibility="gone"
android:id="@+id/create_post_imageview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:adjustViewBounds="true"
android:background="@color/light_grey"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/attach_image_textview" ></ImageView>
<TextView
android:background="@color/purple_500"
android:textColor="@color/white"
android:textStyle="bold"
android:gravity="center"
android:textSize="20dp"
android:text="@string/amity_create_post"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_width="0dp"
android:layout_height="56dp" ></TextView>
</androidx.constraintlayout.widget.ConstraintLayout>
Create an Image Post Creation Fragment
Select Image From File Picker
The most interesting part of the screen could be selecting an image from the user's device. To access users' images we will use the ACTION_OPEN_DOCUMENT intent action that allows users to select a specific document or file to open. Additionally, we can specify the type of the document as image/* to scope only image documents. Upon getting a document URI returned, we can use ContentResolver.takePersistableUriPermission
in order to persist the permission across restarts. Once the image is chosen, we also need to render the image preview by using Glide as well.
class CreatePostFragment : Fragment() {
private val viewModel: CreatePostViewModel by viewModels()
private var binding: FragmentCreatePostBinding? = null
private val openDocumentResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult())
{ result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { imageUri ->
requireActivity().contentResolver.takePersistableUriPermission(
imageUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
renderPreviewImage(imageUri)
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentCreatePostBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.attachImageTextview?.setOnClickListener { openDocumentPicker() }
}
private fun openDocumentPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "image/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
openDocumentResult.launch(intent)
}
private fun renderPreviewImage(uri: Uri) {
binding?.createPostImageview?.let {
it.visibility = View.VISIBLE
Glide.with(requireContext())
.load(uri)
.transition(DrawableTransitionOptions.withCrossFade())
.into(it)
}
}
}
Create an Image Post Creation ViewModel
This screen's view model is primarily responsible for creating an image post. Amity Social Cloud SDK requires two steps to create an image post: the first is to upload the image, and the second is to create a post using the image (see more details in this documentation).
class CreatePostViewModel : ViewModel() {
fun createImagePost(
postText: String,
postImage: Uri,
onPostCreationSuccess: (AmityPost) -> Unit,
onPostCreationError: (throwable: Throwable) -> Unit
): Completable {
return uploadImage(postImage)
.flatMap {
AmitySocialClient.newPostRepository()
.createPost()
.targetMe()
.image(images = arrayOf(it))
.text(text = postText)
.build()
.post()
}
.subscribeOn(Schedulers.io())
.doOnError { onPostCreationError.invoke(it) }
.doOnSuccess { onPostCreationSuccess(it) }
.ignoreElement()
}
private fun uploadImage(imageUri: Uri): Single<AmityImage> {
return AmityCoreClient.newFileRepository()
.uploadImage(uri = imageUri)
.build()
.transfer()
.doOnNext {
when (it) {
is AmityUploadResult.ERROR -> {
throw it.getError()
}
is AmityUploadResult.CANCELLED -> {
throw UPLOAD_CANCELLED_EXCEPTION
}
}
}
.filter { it is AmityUploadResult.COMPLETE }
.map { (it as AmityUploadResult.COMPLETE).getFile() }
.firstOrError()
}
}
val UPLOAD_CANCELLED_EXCEPTION = Exception("Upload has been cancelled")
Now let's go back to the CreatePostFragment
and connect a function in viewModel
to it. In the previous section, we're able to choose an image and render the preview already, we will now pass that image URI to the ViewModel
to create a new post.
class CreatePostFragment : Fragment() {
private val viewModel: CreatePostViewModel by viewModels()
private var binding: FragmentCreatePostBinding? = null
private var imageUri: Uri? = null
private val openDocumentResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult())
{ result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { imageUri ->
requireActivity().contentResolver.takePersistableUriPermission(
imageUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
renderPreviewImage(imageUri)
this.imageUri = imageUri
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentCreatePostBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.attachImageTextview?.setOnClickListener { openDocumentPicker() }
binding?.createPostButton?.setOnClickListener { createPost() }
}
private fun openDocumentPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "image/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
openDocumentResult.launch(intent)
}
private fun renderPreviewImage(uri: Uri) {
binding?.createPostImageview?.let {
it.visibility = View.VISIBLE
Glide.with(requireContext())
.load(uri)
.transition(DrawableTransitionOptions.withCrossFade())
.into(it)
}
}
private fun createPost() {
showToast("Creating a post, please wait..")
val postText = binding?.createPostEdittext?.text.toString()
if (postText.isNotBlank() && imageUri != null) {
viewModel.createImagePost(postText = postText,
postImage = imageUri!!,
onPostCreationError = {
showToast("Post creation error ${it.message}")
},
onPostCreationSuccess = {
findNavController().navigate(R.id.action_CreatePostFragment_to_ImageFeedFragment)
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
} else {
showToast("Either text or image is empty")
}
}
private fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
All set for the image post-creation screen! Let's run the application and see how awesome it is.
Comment List Screen
The structure of the screen is very similar to that of the image feed screen. Let's break it down as much as possible.
- Comment list container - The page that combines all components together (
CommentListFragment
) - Comment list ViewModel - The
ViewModel
class that stores and manages UI-related data in a lifecycle (CommentListViewModel
) - List of comments - The list that shows a collection of comments on the post (
CommentAdapter
) - Empty state - The view when there are no comments on the post
- Progress bar - The view when the comment is loading.
Create a Comment Item Layout
We're again taking a bottom-up approach to creating the image feed screen, which means we'll start with a RecyclerView
holder layout first, and a comment list screen afterwards. The comment item layout will be almost the same as the image feed item layout: the header, which includes the commenter's avatar and display name, as well as the time it was created, and secondly, the comment text, followed by the number of reactions.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/avatar_image_view"
style="@style/AmityCircularAvatarStyle"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginStart="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:background="@color/light_grey"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" ></com>
<LinearLayout
android:id="@+id/layout_posted_by"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginBottom="12dp"
app:layout_constraintStart_toEndOf="@id/avatar_image_view"
app:layout_constraintTop_toTopOf="@id/avatar_image_view">
<TextView
android:id="@+id/display_name_text_view"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="2"
android:textAlignment="textStart"
android:textColor="@color/primary_grey"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
tools:text="Brian Marty" ></TextView>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintLeft_toRightOf="@id/avatar_image_view"
app:layout_constraintTop_toBottomOf="@id/layout_posted_by">
<TextView
android:id="@+id/comment_time_text_view"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="2 hours" ></TextView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<LinearLayout
android:id="@+id/reaction_status_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:ignore="MissingConstraints">
<TextView
android:id="@+id/description_textview"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:textColor="@color/primary_grey"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="My favourite place on earth" ></TextView>
</LinearLayout>
<LinearLayout
android:id="@+id/post_action_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginStart="12dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/reaction_status_view">
<TextView
android:padding="8dp"
android:id="@+id/like_count_textview"
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="12dp"
android:gravity="center_vertical"
android:textColor="@color/primary_grey"
app:drawableStartCompat="@drawable/ic_like_reaction"
tools:text="12 likes" ></TextView>
</LinearLayout>
<RelativeLayout
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/post_action_view"
android:background="@color/light_grey"
android:layout_width="match_parent"
android:layout_height="1dp"></RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
Create a Comment List Screen Layout
As described in the previous section, we only need a RecyclerView to display a comment collection and a compose bar to write a new comment for the comment list screen's layout.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:paddingTop="56dp"
android:layout_marginBottom="56dp"
android:id="@+id/comment_recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent" ></androidx>
<LinearLayout
android:id="@+id/empty_comment_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="visible">
<TextView
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/amity_empty_comment"
android:textColor="@color/black"
android:textStyle="bold" ></TextView>
<TextView
style="@style/TextAppearance.AppCompat.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/amity_empty_comment_description"
android:textColor="@color/black" ></TextView>
</LinearLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_centerInParent="true"
android:visibility="visible"
></com>
<RelativeLayout
android:background="@color/white"
android:elevation="15dp"
android:layout_alignParentBottom="true"
android:layout_width="match_parent"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_height="wrap_content">
<EditText
android:hint="@string/amity_post_create_hint"
android:id="@+id/comment_edit_text"
android:layout_marginEnd="84dp"
android:layout_centerVertical="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"></EditText>
<TextView
android:id="@+id/comment_create_text_view"
android:textSize="18dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:textStyle="bold"
android:layout_marginEnd="16dp"
android:textColor="@color/purple_500"
android:text="@string/amity_comment_create"
android:layout_width="wrap_content"
android:layout_height="wrap_content"></TextView>
</RelativeLayout>
</RelativeLayout>
Create a Comment List ViewModel
The ViewModel
must support the screen's functions of getting a comment collection and creating a new comment. The two functions are handled by a comment repository provided by AmitySoicialClient
.
class CommentListViewModel : ViewModel() {
fun getComments(
postId: String,
onCommentListUpdated: (commentPagedList: PagedList<AmityComment>) -> Unit
): Completable {
return AmitySocialClient.newCommentRepository()
.getComments()
.post(postId = postId)
.includeDeleted(false)
.sortBy(AmityCommentSortOption.LAST_CREATED)
.build()
.query()
.doOnNext { onCommentListUpdated.invoke(it) }
.ignoreElements()
.subscribeOn(Schedulers.io())
}
fun createComment(
postId: String,
commentText: String,
onCommentCreationSuccess: (AmityComment) -> Unit,
onCommentCreationError: (throwable: Throwable) -> Unit
): Completable {
return AmitySocialClient.newCommentRepository()
.createComment()
.post(postId = postId)
.with()
.text(text = commentText)
.build()
.send()
.doOnSuccess { onCommentCreationSuccess.invoke(it) }
.doOnError { onCommentCreationError.invoke(it) }
.ignoreElement()
.subscribeOn(Schedulers.io())
}
}
Create a Comment List Adapter
We now know that the data return PagedList<AmityComment>
model for us to render in the image post item after we created a ViewModel
. To properly construct a RecyclerView
, two components are required for creating an image feed adapter: PagedListAdapter
and ViewHolder
. We replicated the majority of the rendering logic from ImageFeedAdapter
because they have nearly identical presentation perspectives.
ViewHolder
:
class CommentViewHolder(private val binding: ListItemCommentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(comment: AmityComment?) {
presentHeader(comment)
presentContent(comment)
presentFooter(comment)
}
private fun presentHeader(comment: AmityComment?) {
//render commenter's avatar
Glide.with(itemView)
.load(comment?.getUser()?.getAvatar()?.getUrl(AmityImage.Size.SMALL))
.transition(DrawableTransitionOptions.withCrossFade())
.into(binding.avatarImageView)
//render commenter's display name
binding.displayNameTextView.text = comment?.getUser()?.getDisplayName() ?: "Unknown user"
//render commented time
binding.commentTimeTextView.text =
comment?.getCreatedAt()?.millis?.readableFeedPostTime(itemView.context) ?: ""
}
private fun presentContent(comment: AmityComment?) {
comment?.getData()
//make sure that the comment contains text data
if (comment?.getData() is AmityComment.Data.TEXT) {
val commentText = (comment.getData() as AmityComment.Data.TEXT).getText()
binding.descriptionTextview.text = commentText
}
}
private fun presentFooter(comment: AmityComment?) {
//render like count
binding.likeCountTextview.text = getLikeCountString(comment?.getReactionCount() ?: 0)
val isLikedByMe = comment?.getMyReactions()?.contains("like") == true
val context = binding.root.context
val highlightedColor = ContextCompat.getColor(context, R.color.teal_700)
val inactiveColor = ContextCompat.getColor(context, R.color.dark_grey)
if (isLikedByMe) {
//present highlighted color if the comment is liked by me
setLikeTextViewDrawableColor(highlightedColor)
} else {
//present inactive color if the comment isn't liked by me
setLikeTextViewDrawableColor(inactiveColor)
}
//add or remove a like reaction when clicking like textview
binding.likeCountTextview.setOnClickListener {
if (isLikedByMe) {
comment?.react()?.removeReaction("like")?.subscribe()
} else {
comment?.react()?.addReaction("like")?.subscribe()
}
}
}
private fun setLikeTextViewDrawableColor(@ColorInt color: Int) {
for (drawable in binding.likeCountTextview.compoundDrawablesRelative) {
if (drawable != null) {
drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
}
binding.likeCountTextview.setTextColor(color)
}
private fun getLikeCountString(likeCount: Int): String {
return itemView.context.resources.getQuantityString(
R.plurals.amity_number_of_likes,
likeCount,
likeCount
)
}
}
PagedListAdapter
:
class CommentAdapter :
PagedListAdapter<AmityComment, CommentViewHolder>(CommentDiffCallback()) {
override fun onBindViewHolder(holder: CommentViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder {
return CommentViewHolder(
ListItemCommentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
}
private class CommentDiffCallback : DiffUtil.ItemCallback<AmityComment>() {
override fun areItemsTheSame(oldItem: AmityComment, newItem: AmityComment): Boolean {
return oldItem.getCommentId() == newItem.getCommentId()
}
override fun areContentsTheSame(oldItem: AmityComment, newItem: AmityComment): Boolean {
return oldItem.getCommentId() == newItem.getCommentId()
&& oldItem.getUpdatedAt() == newItem.getUpdatedAt()
&& oldItem.getReactionCount() == newItem.getReactionCount()
}
}
Create a Comment List Fragment
Pheww! It will be the last time we put all of our efforts together. A ViewModel
, an adapter, and layouts have all been created. Everything will be condensed into a fragment. The ViewModel
will interact with the fragment, and the view states will be updated. We also have to deal with the comments that are loading, loaded, and empty states.
class CommentListFragment : Fragment() {
private lateinit var adapter: CommentAdapter
private val viewModel: CommentListViewModel by viewModels()
private var binding: FragmentCommentListBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentCommentListBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
getComments()
binding?.commentCreateTextView?.setOnClickListener { createComment() }
}
private fun setupRecyclerView() {
adapter = CommentAdapter()
binding?.commentRecyclerview?.layoutManager = LinearLayoutManager(context)
binding?.commentRecyclerview?.adapter = adapter
}
private fun getComments() {
val postId = arguments?.getString("postId")
postId?.let {
viewModel.getComments(postId = postId) {
lifecycleScope.launch {
handleEmptyState(it.size)
adapter.submitList(it)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
}
}
private fun createComment() {
showToast("Creating comment.. please wait")
val postId = arguments?.getString("postId")
val commentText = binding?.commentEditText?.text ?: ""
if (commentText.isNotBlank() && postId != null) {
viewModel.createComment(postId = postId, commentText = commentText.toString(),
onCommentCreationSuccess = {
binding?.commentEditText?.setText("")
showToast("Comment was created successfully")
},
onCommentCreationError = {
binding?.commentEditText?.setText("")
showToast("Comment error : ${it.message}")
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
} else {
showToast("Comment error : postId or comment is empty")
}
}
private fun handleEmptyState(itemCount: Int) {
binding?.progressBar?.visibility = View.GONE
binding?.emptyCommentView?.visibility = if (itemCount > 0) View.GONE else View.VISIBLE
}
private fun showToast(message: String) {
Snackbar.make(binding!!.root, message, LENGTH_SHORT).show()
}
}
Yessssss, our app is now completed! we hope you enjoyed this tutorial. If you have any obstacles while implementing this tutorial, feel free to explore the code from this repository.
Published at DZone with permission of Trust Ratch. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments