Android All About Page Part 1: Dynamic Design of ViewPager2+TabLayout
This is part one. I have added ViewPager2 and TabLayout. The real world may not put your app in a static position all the time. Dynamic is more flexible. Here is the result:
— === MeNu === —
🔌1. Gradel Dependencies
🚞2. UI Design
🎀3. Adapter of ViewPager2
💭4. Dynamic TabLayout to ViewPager2
♋️5. Test Result
☕6. Espresso Recorder Test(TDD)
🔌1. Gradel Dependencies
< == Menu
Gradle.Project
buildscript {
ext.kotlin_version = "1.4.0"
repositories {
google()
jcenter()
mavenCentral()
maven { url "https://oss.jfrog.org/libs-snapshot" }
}
dependencies {
//classpath 'com.android.tools.build:gradle:4.2.0-alpha09'
// Kotlin
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// Hilt
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
// Android Tools
classpath 'com.android.tools.build:gradle:4.1.0-rc02'
}
}
Gradle: Module: Plugin
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
Gradle: Module: Android
android {
compileSdkVersion 30
buildToolsVersion "30.0.2"
defaultConfig {
applicationId "com.homan.huang.pagingdemo"
minSdkVersion 24
targetSdkVersion 30
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'
}
buildFeatures {
dataBinding true
}
useLibrary 'android.test.runner'
useLibrary 'android.test.base'
useLibrary 'android.test.mock'
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
exclude("META-INF/*.kotlin_module")
}
}
Data-Binding is turned on.
Gradle: Module: Dependencies
// STD
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.appcompat:appcompat:1.2.0'
// Activity and Fragment
def activity_version = "1.1.0"
implementation "androidx.activity:activity-ktx:$activity_version"
def fragment_version = "1.2.5"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Design
implementation 'com.google.android.material:material:1.3.0-alpha02'// ViewPager2
implementation "androidx.viewpager2:viewpager2:1.1.0-alpha01"// Layout
implementation "androidx.constraintlayout:constraintlayout:2.0.1"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
def lifecycle_version = "2.2.0"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Lifecycles only (without ViewModel or LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
def paging_version = "3.0.0-alpha06"
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common-ktx:$paging_version"
def room_version = "2.3.0-alpha02"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
// Hilt
def hilt_version = '2.28-alpha'
def hilt_lifecycle_version = '1.0.0-alpha02'
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_lifecycle_version"
kapt "androidx.hilt:hilt-compiler:$hilt_lifecycle_version"
Test:
// test
testImplementation 'junit:junit:4.13'
// Core library
def testcore_version = '1.3.0'
androidTestImplementation "androidx.test:core:$testcore_version"
def archcore_version = '2.1.0'
androidTestImplementation "androidx.arch.core:core-testing:$archcore_version"
// AndroidJUnitRunner and JUnit Rules
def test_version = '1.3.0'
androidTestImplementation "androidx.test:runner:$test_version"
androidTestImplementation "androidx.test:rules:$test_version"
// Test helpers for LiveData
def arch_version = "2.1.0"
testImplementation "androidx.arch.core:core-testing:$arch_version"
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.ext:truth:1.3.0'
androidTestImplementation 'com.google.truth:truth:1.0'
// Espresso dependencies
def espresso_version = "3.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-accessibility:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-web:$espresso_version"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$espresso_version"
// Test: Kotlin coroutines
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.9'
testImplementation 'org.hamcrest:hamcrest-library:1.3'
It contained SwipeRefreshLayout 1.0.0.
🚞2. UI Design
< == Menu
Design: activity_main.xml =(ViewPager2) => fragment_Foodpage.xml =(RecyclerView) => food_item_view.xml
activity_main.xml
Data binding: The auto-generated binding file is ActivityMainBinding.
<layout 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">
<data>
<variable
name="mainViewModel"
type="PACKAGE.ui.main.MainViewModel" />
</data> <androidx.constraintlayout.widget.ConstraintLayout ...> </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
PACKAGE = YOUR Package Name.
LinearLayout: id as pageBar.
<LinearLayout
android:id="@+id/pageBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
</LinearLayout>
ImageView => cart.png
<ImageView
android:id="@+id/imageView2"
android:layout_width="50dp"
android:layout_height="50dp"
app:srcCompat="@drawable/cart" />
TabLayout: id as pageTab.
<com.google.android.material.tabs.TabLayout
android:id="@+id/pageTab"
.../>
ViewPager2, which displays fragments, id as pager.
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager" .../>
FloatingActionButton : id as menuFab — Show nextFab and resetFab
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/menuFab"
...
app:srcCompat="@drawable/apple" />
FloatingActionButton : id as nextFab
At this moment, ExtendedFloatingActionButton cannot be mixed with FloatingActionButton. But You can design it by CoordinateLayout. In fact, this design has more detail (background image, text color and etc.) and easy to edit, too.
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/nextFab"
...
android:background="@drawable/frame_blue"
...>
<TextView
...
android:text="@string/next"
... />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
FloatingActionButton : id as resetFab
<androidx.co<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/resetFab"
...
android:background="@drawable/frame_red"
...">
<TextView
...
android:text="@string/reset"
.../>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
fragment_foodpage.xml
FoodPageFragment <=> fragment_foodpage.xml.
Data binding: The auto-generated binding file is FragmentFoodpageBinding.
<layout 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">
<data>
<variable
name="foodPageFragmentViewModel"
type="PACKAGE.ui.main.FoodPageFragmentViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout ...> </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
PACKAGE = YOUR Package Name.
ExtendedFloatingActionButton: titleFab with sample text as “Page: 1”.
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/titleFab"
...
android:background="@drawable/blub_frame"
android:backgroundTint="@null"
android:gravity="center"
android:text="@{`Page: `+foodPageFragmentViewModel.pageId}"
...
tools:text="Page: 1" />
One RecyclerView displays the list of food items.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/food_rv" ...
tools:listitem="@layout/food_item_view" />
Point to food_item_view to display the sample.
RecyclerView Item: food_item_view.xml
Data binding: The auto-generated binding file is FoodItemViewBinding.
<layout 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">
<data>
<variable
name="food"
type="PACKAGE.data.entity.Food" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"> </LinearLayout>
</layout>
PACKAGE = YOUR Package Name.
Horizontal LinearLayout:
ImagView binding with: app:myImage=”@{food.image}”
TextView binding with: @{food.id+`. `+food.foodname}
<LinearLayout
...
android:orientation="horizontal">
<ImageView
android:id="@+id/imageView"
...
app:myImage="@{food.image}"
tools:srcCompat="@tools:sample/avatars"
android:contentDescription="@string/food_icon" />
<TextView
android:id="@+id/food_tv"
...
android:text="@{food.id+`. `+food.foodname}"
...
tools:text="@tools:sample/cities" />
</LinearLayout>
I use sample avatars and sample cities to simulate the display.
CustomBinding.kt: binding collection
Once you create an image binding, you need to add a binding function to translate the data. The data binding will search function name by @BindingAdapter in the app namespace; for example, myImage. So you can put binding collection anywhere in your package.
@BindingAdapter("myImage")
fun bindFoodImage(foodImageView: ImageView, resId: Int) {
foodImageView.setImageResource(resId)
}
Food Icons
Food Icons: demoicons.rar — Download and save them the src/drawable.
🎀3. Adapter of ViewPager2
< == Menu
Setup Fragment and ViewModel
🕐 First, the pager displays some fragments and each one of them displays a list of food items; so you need a fragment.
That’s nonsense! You can skip the first sentence. Woohoo…
FoodPageFragment.kt: bind view.
@AndroidEntryPoint
class FoodPageFragment: Fragment() {
private val foodPageViewModel: FoodPageFragmentViewModel by viewModels() private var page = 0
// data binding
private lateinit var fragBinding: FragmentFoodpageBinding
Binding:
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
fragBinding = DataBindingUtil.inflate(
inflater, R.layout.fragment_foodpage, container, false)
fragBinding.apply {
lifecycleOwner = viewLifecycleOwner
foodPageFragmentViewModel = foodPageViewModel
}
return fragBinding.root
}
Get bundle data:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.takeIf { it.containsKey(PAGENUM) }
?.apply {
page = getInt(PAGENUM)
foodPageViewModel.pageId = page
lgd("---> argument() => page: $page")
}
// RecyclerView
val foodRv = view.findViewById<RecyclerView>(R.id.food_rv)
// RecyclerView manager
val rvLayoutManager = GridLayoutManager(context, 1)
}companion object {
private const val tag = "MYLOG PageFg"
fun lgd(s:String) = Log.d(tag, s)
const val PAGENUM = "PageNumber"
}
FooPageFragmentViewModel.kt: ViewModel contains the page# for the binding.
var pageId = 0
You can check fragment_foodpage.xml for the binding.
Adapter of ViewPager2
🕑Second, we need an adapter to connect the ViewPager and the fragment.
PageFragmentStateAdapter.kt, extends FragmentStateAdapter.
class PageFragmentStateAdapter(
fa: FragmentActivity
): FragmentStateAdapter(fa) {
override fun getItemCount(): Int { }
override fun createFragment(position: Int): Fragment { } companion object {
private const val PAGENUM = "PageNumber"
private const val tag = "MYLOG StateAdapter"
fun lgd(s:String) = Log.d(tag, s)
}
}
We need to hold with the list of FoodPageFragment.
// list of fragment
private val pFragments: MutableList<FoodPageFragment> = mutableListOf()
Support count,
override fun getItemCount(): Int {
return pFragments.size
}
Create fragment,
override fun createFragment(position: Int): Fragment {
lgd("createFragment(): page# $position; " +
"List size: ${pFragments.size}")
val fragment = FoodPageFragment()
fragment.arguments = Bundle().apply {
putInt(PAGENUM, position+1)
}
return fragment
}
Update list:
fun addOne(pageNum: Int) {
val mFragment = createFragment(pageNum-1)
pFragments.add(mFragment as FoodPageFragment)
lgd("addOne(): fragment list size = ${pFragments.size}")
}
fun clear() {
pFragments.clear()
}
💭4. Dynamic TabLayout to ViewPager2
< == Menu
MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
private lateinit var mBinding: ActivityMainBinding
private lateinit var viewPagerAdapter: PageFragmentStateAdapter companion object {
val tag = "MYLOG MainAct"
fun lgd(s: String) = Log.d(tag, s)
}
}
Binding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// data binding:
mBinding = DataBindingUtil.setContentView(
this, R.layout.activity_main)
mBinding.apply {
mainViewModel = mainViewModel
lifecycleOwner = this@MainActivity
}
LiveData for Binding:
// observer page #
mainViewModel.page.observe( this,
Observer { page->
if (page > 1) setTab(page)
}
)
Let’s check MainViewModel.kt:
val page:MutableLiveData<Int> = MutableLiveData<Int>()
init {
page.value = 1
}
Buttons Control
// Floating button initial
showMenuFab()
// Floating button: next -- create next tab
nextFab.setOnClickListener {
val pageValue = mainViewModel.page.value!! + 1
lgd("FAB: >> Add 1 page to $pageValue")
mainViewModel.page.value = pageValue // LiveData
showMenuFab() // update FABs
}
resetFab.setOnClickListener {
resetTab()
showMenuFab() // update FABs
}
// Floating button: menu
menuFab.setOnClickListener { hideMenuFab() }
> showMenuFab():
fun showMenuFab() {
menuFab.visibility = View.VISIBLE
nextFab.visibility = View.GONE
resetFab.visibility = View.GONE
}
> hideMenuFab():
fun hideMenuFab() {
menuFab.visibility = View.GONE
nextFab.visibility = View.VISIBLE
resetFab.visibility = View.VISIBLE
}
TabLayout Control: set and reset
> setTab(): Connect tabs and ViewPager by TabLayoutMediator.
fun setTab(page: Int) {
// update adapter
viewPagerAdapter.addOne(page)
viewPagerAdapter.notifyDataSetChanged()
// update tabs
pageTab.addTab(pageTab.newTab().setText("Page $page"))
lgd("setTab(): tabsize = ${pageTab.tabCount}")
TabLayoutMediator(pageTab, pager) { tab, position ->
tab.text = "Page ${position + 1}"
lgd("TabLayoutMediator(): ${position + 1}")
}.attach()
// move to new tab
pageTab.getTabAt(page-1)!!.select()
}
Very simple.
> resetTab(): Reset tab to 1 and ViewPager adapter.
fun resetTab() {
pageTab.removeAllTabs()
val pageValue = mainViewModel.page.value!!
viewPagerAdapter.notifyItemRangeRemoved(0, pageValue)
viewPagerAdapter.clear()
lgd("FAB: reset to 1")
mainViewModel.page.value = 1 // LiveData
setTab(1)
}
☕6. Espresso Recorder(TDD)
< == Menu
The Espresso test should follow the steps of Chapter 5.
Turn on Recorder:
It’s slow on my laptop, Intel i7–8750H, 2.21GH, 32GB Ram.
Add Assertion
You can start to click and choose the assertion. When you finish, save it in a proper name.
It’s not done. We can start to edit recorded steps to enhance the code. You can group the code.
ENUM: Present for Visible and FAB button choice
enum class Present {
EXIST, NOT_EXIST
}
enum class FabStatus {
NEXT, RESET
}
>checkTab()
fun checkTab(pageNum: Int, present:Present) {
val s = "Page $pageNum"
val textView = onView(
allOf(
withText(s),
withParent(
allOf(
withContentDescription(s),
withParent(IsInstanceOf.instanceOf(android.widget.LinearLayout::class.java))
)
),
isDisplayed()
)
)
if (present == EXIST)
textView.check(matches(withText(s)))
else
textView.check(doesNotExist())
}
> checkPageTitle()
fun checkPagerTitle(pageNum: Int) {
val s = "PAGE: $pageNum"
val button = onView(
allOf(
withId(R.id.titleFab), withText(s),
withParent(
allOf(
withId(R.id.swipeRefresh),
withParent(IsInstanceOf.instanceOf(android.widget.FrameLayout::class.java))
)
),
isDisplayed()
)
)
button.check(matches(isDisplayed()))
}
> checkMenuFab()
fun checkMenuFab() {
val imageButton = onView(
allOf(
withId(R.id.menuFab), withContentDescription("Show two buttons"),
withParent(withParent(withId(android.R.id.content))),
isDisplayed()
)
)
imageButton.check(matches(isDisplayed()))
}
> menuFabClick()
fun menuFabClick() {
val floatingActionButton = onView(
allOf(
withId(R.id.menuFab), withContentDescription("Show two buttons"),
childAtPosition(
childAtPosition(
withId(android.R.id.content),
0
),
2
),
isDisplayed()
)
)
floatingActionButton.perform(click())
}
> checkTwoFabs
fun checkTwoFabs() {
// show nextFab
val viewGroup = onView(
allOf(
withId(R.id.nextFab),
withParent(withParent(withId(android.R.id.content))),
isDisplayed()
)
)
viewGroup.check(matches(isDisplayed()))
// show resetFab
val viewGroup2 = onView(
allOf(
withId(R.id.resetFab),
withParent(withParent(withId(android.R.id.content))),
isDisplayed()
)
)
viewGroup2.check(matches(isDisplayed()))
// hide menuFab
val imageButton = onView(
allOf(
withId(R.id.menuFab), withContentDescription("Show two buttons"),
withParent(withParent(withId(android.R.id.content))),
isDisplayed()
)
)
imageButton.check(doesNotExist())
}
> resetFabClick()
fun resetFabClick() {
val coordinatorLayout5 = onView(
allOf(
withId(R.id.resetFab),
childAtPosition(
childAtPosition(
withId(android.R.id.content),
0
),
4
),
isDisplayed()
)
)
coordinatorLayout5.perform(click())
}
> nextFabClick()
fun nextFabClick() {
val coordinatorLayout = onView(
allOf(
withId(R.id.nextFab),
childAtPosition(
childAtPosition(
withId(android.R.id.content),
0
),
3
),
isDisplayed()
)
)
coordinatorLayout.perform(click())
}
> comboClick()
fun comboClick(status: FabStatus) {
menuFabClick()
checkTwoFabs()
if (status == NEXT)
nextFabClick()
else
resetFabClick()
}
> checkPage2to5()
fun checkPage2to5() {
for (destroyed in 2..5) {
checkTab(destroyed, NOT_EXIST)
}
}
> uiTest()
Now, let’s group them and test, < 10 lines.
@Test
fun uiTest() {
for (page in 1..4) {
lgd("checking page: $page")
checkNewPage(page)
comboClick(NEXT)
}
comboClick(RESET)
checkPage2to5()
}