Build an Android HackerNews Client with Jetpack Compose
April 10, 2021
Tags: Jetpack Compose, Kotlin, Android, Retrofit, HackerNews,
We will be building a HackerNews Client that looks like this
Setup
You will need Android Studio Canary to work with Jetpack Compose, if don’t have it already follow the setup guide here.
Start a new project. In the Select a Project Template window, select Empty Compose Activity and fill out rest of the prompts.
We are going to use Retrofit to query HackerNews Api and accompanist(Glide) to fetch images from the internet. Add the following dependencies to gradle(app) file:
dependencies{
// Other dependencies
...
...
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
// OkHttp
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.2")
// JSON Converter
implementation 'com.squareup.retrofit2:converter-gson:2.7.1'
// Navigation - Jetpack Compose
implementation "androidx.navigation:navigation-compose:1.0.0-alpha09"
// Accompanist(Glide) to fetch images from the internet
implementation "dev.chrisbanes.accompanist:accompanist-glide:0.6.2"
}
Create a Utils
file which contains some utilities like fetching favicon and changing time to relative time.
// Utils.kt
object Utils {
fun convertUnixToRelativeTime(time: String): String{
val longTime = time.toLong() * 1000
val currentTime = System.currentTimeMillis()
val SECOND_MILLIS = 1000;
val MINUTE_MILLIS = 60 * SECOND_MILLIS;
val HOUR_MILLIS = 60 * MINUTE_MILLIS;
val DAY_MILLIS = 24 * HOUR_MILLIS;
val timeDifference = currentTime - longTime
when {
timeDifference < MINUTE_MILLIS ->
return "just now"
timeDifference < 2 * MINUTE_MILLIS ->
return " a minute ago"
timeDifference < 50 * MINUTE_MILLIS ->
return "${timeDifference / MINUTE_MILLIS} minutes ago"
timeDifference < 90 * MINUTE_MILLIS ->
return "an hour ago"
timeDifference < 24 * HOUR_MILLIS ->
return "${timeDifference / HOUR_MILLIS} hours ago"
timeDifference < 48 * HOUR_MILLIS ->
return "yesterday"
else ->
return "${timeDifference / DAY_MILLIS} days ago"
}
}
fun getHostUrl(stringUrl: String): String {
val url = URL(stringUrl)
return url.host
}
fun getFaviconUrl(stringUrl: String): String {
val uri = Uri.parse("https://www.google.com/s2/favicons")
.buildUpon()
.appendQueryParameter("domain", getHostUrl(stringUrl))
.build()
return uri.toString()
}
}
Fetching data from HackerNews Api
Everything we get back from the api is an item, so let’s first create a data class to hold the results we will get back from our queries. Create a new class Item.kt
:
// Item.kt
data class Item(
val id: String,
val deleted: String?,
val type: String?,
val by: String?,
val time: String?,
val text: String?,
val dead: String?,
val parent: String?,
val poll: String?,
val kids: List<String>?,
val url: String?,
val score: String?,
val title: String?,
val parts: List<String>?,
val descendants: String?
)
Now that we have the Item
class let’s create Retrofit interfaces to fetch topStories and items. Create a new Kotlin file and name it HackerNewsApiService.kt
and add the following to it:
/**
* Class that defines all the interfaces for
* the api calls we need. (Used for retrofit calls).
*/
private const val BASE_URL = "https://hacker-news.firebaseio.com/v0/"
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
interface HackerNewsApiService {
@GET("topstories.json")
suspend fun getTopStoriesItemIds(): List<String>
@GET("item/{id}.json")
suspend fun getItemFromId(@Path("id") id: String): Item
}
object HackerNewsApi {
val retrofitService: HackerNewsApiService by lazy {
retrofit.create(HackerNewsApiService::class.java)
}
}
Finally we create our ViewModel which will fetch and hold all of our data. In the ViewModel we first fetch ids of all the topStories and then we fetch a few stories at a time from the ids.
The ViewModel also contains onStoryClicked
and checkEndOfList
functions which will be used when a story is clicked and to fetch more stories when we reach the end of fetched top stories.
Create a new Kotlin class named TopStoriesViewModel
:
class TopStoriesViewModel : ViewModel() {
private val api: HackerNewsApiService = HackerNewsApi.retrofitService
private var topStoriesIds: List<String> by mutableStateOf(listOf())
var topStories: List<Item> by mutableStateOf(listOf())
private set
private var start: Int = 0
private var end: Int = 20
lateinit var clickedStory: Item
var storyComments: List<Item> by mutableStateOf(listOf())
var storyCommentsDepth: List<Int> = mutableListOf()
init {
fetchTopStories()
}
/**
* A function that fetches ids of all the
* top stories and fetches the first batch of
* story items.
*/
private fun fetchTopStories() {
viewModelScope.launch {
try {
topStoriesIds = api.getTopStoriesItemIds()
fetchTopStoriesItems()
} catch (e: Exception) {
Log.e("ViewModel:Fetch Top Stories Ids", e.toString())
}
}
}
/**
* Fetches Top Stories of the ids ranging
* from integer start to integer end.
*/
private fun fetchTopStoriesItems() {
viewModelScope.launch {
try {
for (id in topStoriesIds.subList(start, end)) {
val item = api.getItemFromId(id)
topStories = topStories + listOf(item)
}
} catch (e: Exception) {
Log.e("ViewModel:Fetch Stories", e.toString())
}
}
}
/**
* Fetch more stories when end of the list is reached.
* Updates start and end variables.
*/
private fun addStories() {
start = end
if (start >= topStoriesIds.size) {
return
}
end += 20
fetchTopStoriesItems()
}
/**
* Function that checks if the given index is the
* last element of the stories we have fetched. If
* the last index is reached new stories are fetched
* and added to the list.
*/
fun checkEndOfList(index: Int) {
if (index == topStories.size - 1) {
addStories()
}
}
fun onStoryClicked(item: Item) {
clickedStory = item
fetchStoryComments()
}
fun fetchStoryComments() {
storyComments = listOf()
storyCommentsDepth = listOf()
clickedStory.kids?.let {
viewModelScope.launch {
for (id in it) {
fetchCommentItem(id, 0)
}
}
}
}
/**
* A recursive function that fetches all the comments
* in the order(Depth First) you would see on hacker news.
*/
private suspend fun fetchCommentItem(id: String, depth: Int) {
try {
val item = api.getItemFromId(id)
storyComments = storyComments + listOf(item)
storyCommentsDepth = storyCommentsDepth + listOf(depth)
item.kids?.let {
for (kId in item.kids) {
fetchCommentItem(kId, depth + 1)
}
}
} catch (e: Exception) {
Log.e("ViewModel:Fetch Comments", e.toString())
}
}
}
We now have everything setup to query the api and hold the results. We can now start building our UI.
Building our UI with Jetpack Compose
Top Stories
I created all of the following composables in a new file TopStories.kt
because all of them will be used to display the list of top stories. You could create these after the MainActivity or you could create a new file for every single one of them. Follow a structure that makes sense to you.
Top Stories List Item
Let’s start with the list item for the top stories. Here we create a composable which takes strings and modifier as inputs. We want to keep our composables as simple as possible so that its simple to preview them.
We want to create the following:
Looking at the image above we can see that there are 3 parts to it: left - which is points/score, middle - title and metadata and right - comment icon and number of comments.
So we will build these three parts and put them in a row using Row
composable and we will wrap it with a Card
composable.
Left:
@Composable
fun StoryScore(score: String){
Text(
text = score,
style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.primary,
modifier = Modifier
.padding(8.dp)
.width(50.dp)
)
}
@Preview
@Composable
fun PreviewStoryScore(){
StoryScore(score = "100")
}
Middle:
@Composable
fun StoryTitleAndMetadata(title: String, author: String, relativeTime: String, modifier: Modifier){
Column(modifier = modifier) {
// Title of the story
Text(text = title, style = MaterialTheme.typography.subtitle1)
// Author and Time metadata
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = "by $author | $relativeTime",
style = MaterialTheme.typography.body2
)
}
}
}
@Preview
@Composable
fun PreviewStoryTitleAndMetadata(){
StoryTitleAndMetadata(
title = "Test Title",
author = "Avin",
relativeTime = "10 hours ago",
modifier = Modifier
)
}
Right:
@Composable
fun StoryCommentIconAndCount(count: String){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_comment_24),
contentDescription = "Comment Icon",
tint = MaterialTheme.colors.onSurface
)
Text(text = count, modifier = Modifier.align(Alignment.End))
}
}
@Preview
@Composable
fun PreviewStoryCommentIconAndCount(){
StoryCommentIconAndCount(count = "20")
}
Now we combine all the three parts. We add weight to middle section because we want it to be the longest(as much as it can stretch). If you notice we are also passing modifier
to this composable because we want to be able to add padding and onClick functionality from the calling function.
@Composable
fun StoryListItem(
score: String,
title: String,
author: String,
relativeTime: String,
commentsCount: String?,
modifier: Modifier
){
Card(modifier = modifier){
Row(verticalAlignment = Alignment.CenterVertically) {
// Score / Points
StoryScore(score)
// Title + metadata
StoryTitleAndMetadata(
title = title,
author = author,
relativeTime = relativeTime,
modifier = Modifier.weight(1f)
)
// Comment icon + comment count
var count = "0"
commentsCount?.let {
count = it
}
StoryCommentIconAndCount(count = count)
}
}
}
@Preview
@Composable
fun PreviewStoryListItem(){
StoryListItem(
score = "10",
title = "Test Title",
author = "Avin",
relativeTime = "10 hours ago",
commentsCount = "20",
modifier = Modifier
)
}
Finally we create another composable with the same name StoryListItems
which takes 3 arguments:
- an
Item
which contains all the details of the TopStory - a function
onItemClicked
which will be called when the story is clicked - a
navController
which will allow us to navigate to the details screen when a story is clicked(we will setup navigation later after we are done with all our composables).
The purpose this composable is to add onClick functionality and do any computations required before calling StoryListItems
.
Creating a composable which takes in simple inputs and just displays them without any processing makes them easy to preview and debug. Then we call them from another composable which might do some operations on the data that will then be passed on to the simple composable.
@Composable
fun StoryListItem(
item: Item,
onItemClicked: (item: Item) -> Unit,
navController: NavController
) {
StoryListItem(
score = item.score!!,
title = item.title!!,
author = item.by!!,
relativeTime = Utils.convertUnixToRelativeTime(item.time!!),
commentsCount = item.descendants,
modifier = Modifier
.padding(8.dp)
.clickable {
onItemClicked(item)
navController.navigate("storyDetails")
}
)
}
Top Stories List
Now that we have our list item composable ready let’s create a list of top stories.
Since we want a vertical list, we will use the LazyColumn
composable, for horizontal list we would have used LazyRow
.
To display a list of stories we just need the following:
LazyColumn() {
// List of items
itemsIndexed(storyItems) { index, item ->
StoryListItem(item = item, onItemClicked, navController)
// Load more items
checkEndOfList(index)
}
}
We will create a new composable StoryList
to hold our list and add a header as well. We pass the following arguments:
storyItems
: list of story itemscheckEndOfList
: a function to check the end of list and load more items if we reach the endonItemClicked
: will be called when a story is clicked, saves the story being clicked so that we know what to display on the next screen.navController
: NavController which will used to navigate us to the details screen when a story is clicked.
Both onItemClicked
and navController
are passed on to every single StoryListItem.
@Composable
fun StoryList(
storyItems: List<Item>,
checkEndOfList: (index: Int) -> Unit,
onItemClicked: (item: Item) -> Unit,
navController: NavController
) {
// Remember our own LazyListState, can be
// used to move to any position in the column.
val listState = rememberLazyListState()
@OptIn(ExperimentalFoundationApi::class)
LazyColumn(state = listState) {
// Header
stickyHeader {
StoryListHeader()
}
// List of items
itemsIndexed(storyItems) { index, item ->
StoryListItem(item = item, onItemClicked, navController)
// Load more items
checkEndOfList(index)
}
}
}
@Composable
private fun StoryListHeader() {
Surface(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
Text(
text = "Top Stories",
style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center
)
}
}
Finally we create a top level composable TopStoriesScreen
which will be called whenever we want to display Top Stories.
@Composable
fun TopStoriesScreen(navController: NavController,
topStoriesViewModel: TopStoriesViewModel
) {
Surface(color = MaterialTheme.colors.background) {
StoryList(
storyItems = topStoriesViewModel.topStories,
topStoriesViewModel::checkEndOfList,
topStoriesViewModel::onStoryClicked,
navController
)
}
}
Details screen - Story header, link, favicon and comments.
I created all of the following composables in a new file StoryDetails.kt
because all of them will be used to display details of the story we clicked on. You could create these after the MainActivity or you could create a new file for every single one of them. Follow a structure that makes sense to you.
We are building a screen that will finally look like this:
Header
Let’s create a header that will display the following:
- Title of the story
- Text(if there is any)
- Link and favicon of the link
- Score/Points along with number of comments
The layout is pretty straight forward, we either have text or rows inside a column. To keep every thing clean and manageable we are going to create FaviconAndUrl
composable and StoryMetadata
composable, instead of dumping all of it in the StoryDetailsHeader
.
@Composable
fun StoryDetailsHeader(
title: String,
text: String?,
url: String?,
score: String,
descendants: String,
relativeTime: String,
author: String
){
Column {
// Title of the post
Text(
text = title,
style = MaterialTheme.typography.h4
)
text?.let {
val text = HtmlCompat.fromHtml(it, Html.FROM_HTML_MODE_LEGACY).toString()
Text(
text = text,
style = MaterialTheme.typography.body1
)
}
// Display favicon with base url and a clickable icon
// that takes you to the original link.
url?.let {
FaviconAndUrl(
url = it,
modifier = Modifier.padding(top = 16.dp)
)
}
StoryMetadata(
score,
descendants,
relativeTime,
author
)
}
}
Then we create FaviconAndUrl
which is a simple row which contains a Favicon, url and a button to visit the url.
@Composable
fun FaviconAndUrl(url: String, modifier: Modifier){
val baseUrl = Utils.getHostUrl(url)
val urlIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val context = LocalContext.current
Row(modifier = modifier) {
FaviconImage(url = url, modifier = Modifier.size(24.dp))
Text(text = baseUrl, modifier = Modifier.padding(start = 8.dp, end = 8.dp))
Icon(
painter = painterResource(id = R.drawable.ic_baseline_open_in_new_24),
contentDescription = "Button that opens the story url",
tint = MaterialTheme.colors.onSurface,
modifier = Modifier.clickable {
startActivity(context, urlIntent, Bundle.EMPTY)
}
)
}
}
We used FaviconImage
composable above which fetches and displays favicon image. Since fetching was not as straight forward I create a composable for it. This allows me to fetch and display a favicon with a single line of code FaviconImage(url = url, modifier = Modifier.size(24.dp))
like we did above.
@Composable
fun FaviconImage(url: String, modifier: Modifier){
// Fetch base url to further use it to fetch favicon
val faviconUrl = Utils.getFaviconUrl(url)
GlideImage(
data = faviconUrl,
contentDescription = "My content description",
loading = {
Box(Modifier.matchParentSize()) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
},
error = { error ->
Log.e("Image Fetch Error!", error.toString())
Icon(
painter = painterResource(id = R.drawable.ic_default_favicon_24),
contentDescription = "Favicon of the url ${Utils.getHostUrl(url)}"
)
},
modifier = modifier
)
}
The last component in the header is the story meta data, which is again just a row of text with some formatting.
@Composable
fun StoryMetadata(
score: String,
commentsCount: String,
relativeTime: String,
author: String
){
Column() {
Row() {
Text(text = score, color = MaterialTheme.colors.primary, style = MaterialTheme.typography.h6)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(text = "p")
}
Spacer(modifier = Modifier.width(16.dp))
Icon(painter = painterResource(id = R.drawable.ic_baseline_comment_24),
contentDescription = "Comment Icon")
Text(text = commentsCount)
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = "by $author | $relativeTime",
style = MaterialTheme.typography.body2
)
}
}
}
With this our header is ready to use.
Comments List Item
We are fetching comments in depth-first order and while we are doing that we also record their depths. Then we simply display the comments in the order we fetched them and change their left padding according to their depth.
@Composable
fun CommentListItem(author: String, relativeTime: String, body: String, depth: Int) {
Card(modifier = Modifier.padding(start = (depth * 8).dp, top = 8.dp), elevation = 2.dp) {
Column() {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = "by $author | $relativeTime",
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 4.dp)
)
}
Row(
Modifier
.height(IntrinsicSize.Max)
.padding(4.dp)
) {
Spacer(modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(MaterialTheme.colors.primary))
Text(text = body, modifier = Modifier.padding(start = 4.dp))
}
}
}
}
@Preview
@Composable
fun PreviewCommentListItem() {
CommentListItem(
author = "Avin",
relativeTime = "1 hour ago",
body = "This is a test comment",
depth = 1
)
}
Followed by another composable with the same name, which does some checks and processing for us before calling CommentListItem
:
@Composable
fun CommentListItem(comment: Item, depth: Int) {
if (comment.deleted == "true"){
Card(
Modifier
.fillMaxWidth()
.padding(top = 8.dp)) {
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(text = "deleted", Modifier.padding(start = 4.dp))
}
}
return
}
val author = comment.by!!
val relativeTime = Utils.convertUnixToRelativeTime(comment.time!!)
val text = HtmlCompat.fromHtml(comment.text!!, FROM_HTML_MODE_LEGACY).toString().trim()
CommentListItem(author = author, relativeTime = relativeTime, body = text, depth = depth)
}
Comments List + Header
Finally we create a list where the first item is our header followed by the list of comments in a composable called StoryDetailsScreen
:
@Composable
fun StoryDetailsScreen(story: Item, comments: List<Item>, depths: List<Int>){
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.padding(16.dp)
) {
// Remember our own LazyListState
val listState = rememberLazyListState()
// Provide it to LazyColumn
LazyColumn(state = listState) {
item {
StoryDetailsHeader(
title = story.title!!,
text = story.text,
url = story.url,
score = story.score!!,
descendants = story.descendants!!,
relativeTime = Utils.convertUnixToRelativeTime(story.time!!),
author = story.by!!
)
}
itemsIndexed(comments) { index, commentItem ->
CommentListItem(comment = commentItem, depth = depths[index])
}
}
}
}
Setup Navigation
Finally we create our navigation in the MainActivity:
class MainActivity : ComponentActivity() {
private val mainViewModel by viewModels<TopStoriesViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
DepthFirstTheme {
// A surface container using the 'background' color from the theme
NavHost(navController = navController, startDestination = "topStories") {
composable("topStories") {
TopStoriesScreen(navController, mainViewModel)
}
composable("storyDetails") {
StoryDetailsScreen(
mainViewModel.clickedStory,
mainViewModel.storyComments,
mainViewModel.storyCommentsDepth
)
}
}
}
}
}
}
We have a single Activity and two screens/composables, so we create a navController here which is passed on to composables which need them. Since all the data and states are in the ViewModel which stays current as we interact with the app, we can setup our navigation in the MainActivity and pass on data and functions that the composables need from the ViewModel.
With this we have a working HackerNews Client made with JetPack Compose. That should look something like this:
You can find the source code here on Github.