Skip to content

Fragments

Malachi de AElfweald edited this page Dec 10, 2018 · 29 revisions

Goal

Convert our Activity-based UI to a Fragment-based UI.

Table of Contents

In the next chapter, we are going to take a look at animation.

Before we can animate the transition, we need to switch from an Activity-based application to a Fragment-based application.

Currently, we have our BookListActivity and our EditBookActivity. Each of these are standalone, only one being active at a time.

The Fragment design allows us to have one or more active at the same time. We end up with one Activity that owns whichever Fragments are currently active.

Why does this matter? Well, in our case specifically, we are going to ask the Fragment Manager to show the animation when switching between two fragments.

Normally, you would refactor the activity to become a fragment; but in this case, we will make new classes to make it obvious what the differences are.

Creating the Layouts

Our layouts are exactly the same in both Activity-based and Fragment-based. In a production app, you would just rename the file. In our case, we will keep both around around so it's clear what the difference is between the two approaches.

fragment_edit_book.xml

Right-click on your res/layout/activity_edit_book.xml and choose Copy.

Right-click on your res/layout folder and choose Paste.

Copy Fragment

Enter fragment_edit_book.xml as the new name and click OK.

fragment_book_list.xml

Copy activity_book_list.xml to fragment_book_list.xml

Deploy your application to regenerate binding files.

Creating the Fragments

Just like before, we are going to create new classes to represent these layouts.

EditBookFragment

Now, you might be tempted to use the New | Fragment | Fragment (Blank) option here, but it adds a lot of unnecessary cruft that you don't need. Let's do it by hand instead.

Just like you've done before, create a new Kotlin class in your ui package called EditBookFragment.

It will extend androidx.fragment.app.Fragment.

package com.aboutobjects.curriculum.readinglist.ui

import androidx.fragment.app.Fragment

class EditBookFragment: Fragment() {
}

Add a new var to hold our binding class.

private lateinit var binding: FragmentEditBookBinding

Override the onCreateView method to reference our new layout.

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    binding = DataBindingUtil.inflate(inflater, R.layout.fragment_edit_book, container,false)
    return binding.root
}

BookListFragment

Create a BookListFragment and do the same for R.layout.fragment_book_list.

package com.aboutobjects.curriculum.readinglist.ui

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import com.aboutobjects.curriculum.readinglist.R
import com.aboutobjects.curriculum.readinglist.databinding.FragmentBookListBinding

class BookListFragment: Fragment() {

    private lateinit var binding: FragmentBookListBinding

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_book_list, container, false)
        return binding.root
    }
}

Our v2 Activity

While we are talking about changing from an Activity-based layout to a Fragment-based layout; we actually still need at least one Activity to launch our application.

v2 Layout

activity_book_list_v2.xml

Create a new layout, activity_book_list_v2.xml with a layout root element.

This one will be much simpler than our previous ones. It's just going to contain a single container.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>

    </data>
    <FrameLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</layout>

Deploy your application to regenerate the binding files.

BookListV2Activity

BookListV2Activity

And for the class to represent that layout, create a new BookListV2Activity in your readinglist package.

We will once again extend AppCompatActivity, which in turn extends FragmentActivity.

package com.aboutobjects.curriculum.readinglist

import androidx.appcompat.app.AppCompatActivity

class BookListV2Activity: AppCompatActivity() {
}

Use the Quick Fix to add the activity to the manifest.

AndroidManifest

AndroidManifest.xml

Open your AndroidManifest.xml and copy the intent-filter from .BookListActivity to .BookListV2Activity.

<activity android:name=".BookListV2Activity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>

        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

Highlight the entire intent-filter block of code for the original BookListActivity. Use Ctrl+/ (or Command+/ on a Mac) to comment out that block of code.

You can deploy your application now, but it won't do much. The old application code is still here, but it is no longer being launched.

If you choose to keep both, you can add an android:label to the activity element to give them each different names in the phones' application drawer.

If you wanted to keep both active by default, you could change your configuration under Run | Edit Configurations to specify which one should start up.

supportFragmentManager

To switch between fragments, we use the supportFragmentManager.

build.gradle

In your module-level build.gradle add another KTX dependency.

implementation 'androidx.fragment:fragment-ktx:1.0.0'

This dependency gives us extensions for managing fragment transactions more cleanly.

Sync your application.

Back in BookListV2Activity

Add our binding var

private lateinit var binding: ActivityBookListV2Binding

Override the onCreate(savedInstanceState: Bundle?) method. There are a lot of onCreate methods, so make sure you grab the correct one.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = DataBindingUtil.setContentView(this, R.layout.activity_book_list_v2)

    savedInstanceState?.let {
        Log.d(ReadingListApp.TAG, "UI already loaded")
    } ?: let {
        supportFragmentManager
            .beginTransaction()
            .add(R.id.container, BookListFragment())
            .commitAllowingStateLoss()
    }
}

This is similar to what we did before. We are now adding a check for the savedInstanceState. Most of the time, developers do not put that initial block in - but we will include it to help us better understand what is happening.

In the case where the savedInstanceState is null, we then request that the supportFragmentManager (based on the KTX component we just added) add a new instance of our BookListFragment to the @+id/container in our activity_book_list_v2.xml.

Deploy your application.

We should probably make the new version do something?

Business Logic Decisions

Before, where logic should exist was obvious. Now, we have decisions to make.

While it is clear that editing should be done in the EditBookFragment, should we read the ReadingList in the BookListV2Activity or in the BookListFragment?

Surprisingly, it could be done either way and you will find that people will not always agree. In fact, you might not agree with yourself a few months into the project.

There may actually be another option for us. What if neither class does?

BookListService

We are going to introduce the concept of a BookListService. While it will not extend android.app.Service, it does have similar goals in mind.

A typical Android Service is defined as A Service is an application component that can perform long-running operations in the background, and it doesn't provide a user interface.

So while we are not extending that class, it is typical for developers to use the same terminology as the intention is clear to everyone. If we were to call it BookListUtil, for example, it starts to become a kitchen sink that gets muddied with everything to do with book lists.

So let's be clear. Our BookListService will be for saving and loading of list of books; and will have no UI.

service package

Right-click on your readinglist package and create a New | Package called service.

BookListService.kt

In it, create a new class called BookListService.

package com.aboutobjects.curriculum.readinglist.service

import com.aboutobjects.curriculum.readinglist.ReadingListApp

class BookListService(val app: ReadingListApp) {
}

You will notice that we are passing in our ReadingListApp as a parameter. This will serve as our UI-less Context.

Note: In a production application, it is recommended that you would use dependency injection using something like Dagger, but that is out of scope for this chapter.

ReadingListApp

Open your ReadingListApp and create a val for the new service.

val bookListService: BookListService by lazy {
    BookListService(app = this)
}

The first time the bookListService is referenced, it will call the BookListService constructor. If we remove the by lazy, it would be initialized immediately, probably before the UI is even loaded.

Copying Logic

Now, we will copy a lot of the logic from the original BookListActivity into the new generic BookListService. You'll want to have both classes open.

JSON_FILE

We'll copy the Json filename into BookListService.

companion object {
    const val JSON_FILE = "BooksAndAuthors.json"
}

BehaviorSubject

Add a variable in BookListService (not in the companion object) to store the current reading list.

val readingList: BehaviorSubject<ReadingList> = BehaviorSubject.create()

A BehaviorSubject allows us to cache the current value. You can read the current value, or wait for a value to be set.

loadFrom functions

Next, we'll grab our old load.. functions from BookListActivity. With some minor changes to the method names and the logs, add them to our BookListService.

private fun loadFromAssets(): Maybe<ReadingList> {
    return try{
        val reader = InputStreamReader(app.assets.open(JSON_FILE))
        Maybe.just(app.gson.fromJson(reader, ReadingList::class.java))
    } catch (e: Exception) {
        Log.d(ReadingListApp.TAG, "Failed to load Json from assets/${BookListService.JSON_FILE}",e)
        Maybe.empty<ReadingList>()
    }
}

private fun loadFromFiles(): Maybe<ReadingList> {
    return try {
        val reader = FileReader(File(app.filesDir, JSON_FILE))
        Maybe.just(app.gson.fromJson(reader, ReadingList::class.java))
    } catch (e: Exception) {
        Log.d(ReadingListApp.TAG, "Failed to load Json from files/${BookListService.JSON_FILE}",e)
        Maybe.empty<ReadingList>()
    }
}

You'll also notice that we are referencing the app for the context.

loadJson

Next, we'll grab the logic that was in both loadJson() and onCreate and put them into a new BookListService.init block.

init {
    val loadDisposable = loadFromFiles()
        .switchIfEmpty(loadFromAssets())
        .subscribeOn(Schedulers.newThread())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribeBy(
            onSuccess = readingList::onNext,
            onError = { Log.w(ReadingListApp.TAG, "Error loading books", it) },
            onComplete = { Log.w(ReadingListApp.TAG, "No books found") }
        )
}

It's generally a good idea to put this block about the function definitions; but it could go above or below the var/val definitions.

This function will get called when the class is initialized, so might get executed before we have any UI (depending on the by lazy above), so we are not showing any UI errors. Besides, remember that our Service does not use the UI.

The onSuccess might look a little weird. Here we are using a method reference shortcut to call readingList.onNext(it). We can't do the same thing for the logs because the parameters don't match.

You'll also notice that while we do grab the loadDisposable, we don't do anything with it. We're grabbing it to keep the IDE from complaining, but there is no concept of application-level cleanup. While there is a onTerminate function on our ReadingListApp, it will never get called in production.

saveJson

Next, we'll copy our saveJson function over and rename.

fun save(readingList: ReadingList): Completable {
    return try{
        val writer = FileWriter(File(app.filesDir, BookListService.JSON_FILE))
        app.gson.toJson(readingList, writer)
        writer.flush()
        writer.close()
        this.readingList.onNext(readingList)
        complete()
    }catch(e: Exception){
        Log.d(ReadingListApp.TAG, "Failed to save Json to files/${BookListService.JSON_FILE}",e)
        error(e)
    }
}

You'll use the io.reactivex.Completable.complete import.

The one key difference here is that we are adding the this.readingList.onNext(readingList) before we return success. This allows us to make sure they are in sync.

You might remember that we also did that in the init function above. Let's change the init function to call this save function instead:

.subscribeBy(
    onSuccess = { readingList -> save(readingList) },
    onError = { Log.w(ReadingListApp.TAG, "Error loading books", it) },
    onComplete = { Log.w(ReadingListApp.TAG, "No books found") }
)

onActivityResult

Lastly, let's copy the edit section from onActivityResult over into a new edit function.

fun edit(source: Book?, edited: Book): Completable {
    return readingList.value?.let { oldReadingList ->
        // Create a new ReadingList
        val newReadingList = ReadingList(
            title = oldReadingList.title,
            books = oldReadingList.books
                .toMutableList()
                .filterNot {
                    it.title == source?.title
                    && it.author == source?.author
                    && it.year == source?.year
                }.plus(edited)
                .toList()
        )
        // Save the results
        save(newReadingList)
    } ?: error(IllegalArgumentException("Reading List not found"))
}

You might be wondering why we are stopping at the save call. If you look at the original code, it would next update the UI. Since the Service doesn't deal with the UI, we will still want to do that in the caller.

Completing the Fragments

EditBookFragment

Open EditBookFragment.

Reference our Service

Let's first create a couple variables to hold our new service.

private var app: ReadingListApp? = null
private var bookListService: BookListService? = null

And then set them up when the Fragment is attached. Make sure to override the correct method as there are two with the same name.

override fun onAttach(context: Context) {
    super.onAttach(context)
    override fun onAttach(context: Context) {
        super.onAttach(context)
        app = context.applicationContext as? ReadingListApp
        bookListService = app?.bookListService
    }
}

We'll make sure to clean up when we are done.

override fun onDetach() {
    super.onDetach()
    bookListService = null
    app = null
}

newInstance

We need a way to create an instance of this class with for a specified Book. While we could use the constructor, that causes problems when the system attempts to recreate the UI (say on low memory or rotation).

Instead, we will create a static method to be called. This is similar to what we did earlier for the Intent.

companion object {
    val PARAM_SOURCE_BOOK = "${EditBookFragment::class.java}.name::source"

    @JvmStatic
    fun newInstance(app: ReadingListApp, source: Book? = null): EditBookFragment {
        return EditBookFragment().apply {
            arguments = Bundle().also { bundle ->
                source?.let { book ->
                    bundle.putString(PARAM_SOURCE_BOOK, app.gson.toJson(book))
                }
            }
        }
    }
}

You'll notice that we added a @JvmStatic. This is a Kotlin annotation that tells the compiler to make sure it can be called statically from Java code as well.

We are also passing in the app because, as a static context, the instance-level one does not exist yet.

Retrieve the source book

To retrieve the parameter value, we add a couple more variables (outside of the companion object).

private val paramSource: String? by lazy { arguments?.getString(PARAM_SOURCE_BOOK) }
private val sourceBook: Book? by lazy { app?.gson?.fromJson(paramSource, Book::class.java) }

At this point, the app does exist.

onCreate

We will want to copy some logic over from EditBookActivity.onCreate into EditBookFragment.onCreateView.

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    binding = DataBindingUtil.inflate(inflater, R.layout.fragment_edit_book, container,false)
    sourceBook?.let {
        binding.book = EditableBook(source = it)
    } ?: let {
        binding.book = EditableBook()
    }
    return binding.root
}

onCreateOptionsMenu

Copy logic from onCreateOptionsMenu (note the signature is different)

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.menu_edit_book, menu)
}

onActivityCreated

Unlike before, we need to tell the system that we want to show those options, or our save icon will not appear.

Override onActivityCreated.

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    setHasOptionsMenu(true)
}

onOptionsItemSelected

Our onOptionsItemSelected will use the same concept as before, but in a slightly different way.

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when(item.itemId) {
        R.id.action_save -> {
            binding.book?.let { editableBook ->
                bookListService?.let { service ->
                    service.edit(
                        source = editableBook.source,
                        edited = editableBook.edited() )
                        .subscribeOn(Schedulers.newThread())
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribeBy(
                            onComplete = {
                                // It's updated, so we are done
                                activity?.supportFragmentManager?.popBackStack()
                            },
                            onError = { t ->
                                t.message?.let { msg ->
                                    Snackbar.make(binding.root, msg, Snackbar.LENGTH_LONG)
                                        .show()
                                }
                            }
                        )
                }
            }
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

You'll import com.google.android.material.snackbar.Snackbar.

If we successfully edit the book, we popBackStack, ie return back to the previous screen. This is basically hitting the back arrow.

BookListFragment

Open your BookListFragment.

Reference our Service and Fragment Manager

Like with EditBookFragment, let's first create a couple variables to hold our new service. We'll also reference our Fragment Manager since we will be re-using it.

private var app: ReadingListApp? = null
private var bookListService: BookListService? = null
private var fragManager: FragmentManager? = null

And then set them up when the Fragment is attached. Make sure to override the correct method as there are two with the same name.

override fun onAttach(context: Context) {
    super.onAttach(context)
    app = context.applicationContext as? ReadingListApp
    bookListService = app?.bookListService
    fragManager = (context as BookListV2Activity).supportFragmentManager
}

Make sure to do our cleanup.

override fun onDetach() {
    super.onDetach()
    bookListService = null
    app = null
    fragManager = null
}

viewAdapter

Continuing to copy functionality over, we are going to make a new version of our viewAdapter.

    private val viewAdapter = ReadingListAdapter(
        bookClicked = { book ->
            app?.let { readingListApp ->
                fragManager?.let {
                    it.beginTransaction()
                    .replace(R.id.container, EditBookFragment.newInstance(
                        app = readingListApp,
                        source= book))
                    .addToBackStack(null)
                    .commit()
                }
            }
        }
    )

You'll notice that the IDE suggests removing the let on the fragManager?.let line. While it is possible to do so, that results in adding a ? before every period. Feel free to try it and see.

Since this could be called before the app is configured, we have to account for that.

Before, we used .add to put our current fragment into the R.id.container. Now we will use .replace to swap it out for the editing page. We are also using .addToBackstack so that our popBackStack will allow us to return to this page.

onCreate

We'll continue copying from BookListActivity.onCreate into BookListFragment.onCreateView.

Our recycler looks the basically the same. We just need to deal with the Context differently.

binding.recycler.apply {
    setHasFixedSize(true)
    activity?.let {
        layoutManager = LinearLayoutManager(it)
        addItemDecoration(CustomDivider(context = it as Context))
    }
    adapter = viewAdapter
}

For the FAB, we want to mimic what we did for our bookClicked listener above; but without a 'source' book.

binding.fab.setOnClickListener {
    app?.let { readingListApp ->
        fragManager?.let {
            it.beginTransaction()
            .replace(R.id.container, EditBookFragment.newInstance(app = readingListApp))
            .addToBackStack(null)
            .commit()
        }
    }
}

Monitoring books being loaded

To monitor for books being loaded, we want to add our disposal variable up above first.

private var disposable: Disposable? = null

Then watch the BehaviorSubject for changes and update our viewAdapter inside onCreateView.

bookListService?.let { service ->
    disposable = service.readingList
        .toFlowable(BackpressureStrategy.LATEST)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            Log.i(ReadingListApp.TAG, "${it.books.size} books loaded")
            viewAdapter.readingList = it
        }
}

And lastly, make sure to dispose of it when we don't need it anymore.

override fun onDetach() {
    super.onDetach()
    disposable?.dispose()
    bookListService = null
    app = null
    fragManager = null
}

Deploy your application and make sure everything is working.

Clone this wiki locally