ViewPager2 jako świetny sposób na samouczek. Część 1

ViewPager2 jako świetny sposób na samouczek. Część 1

Świetnym sposobem na przedstawienie możliwości aplikacji jest dodanie ekranu samouczka. W tym wpisie pokaże Ci, w jaki sposób przygotować taki samouczek, który posiada ładne animacje oraz wykorzystuje nowy ViewPager2. Dodatkowo będzie zawierał implementację modelu widoku, który to udostępni listę na podstawie której zbudujemy ekrany samouczka.

Efekt końcowy

Z czego skorzystamy?

Poniżej znajdziesz mechanizmy, narzędzia oraz rozwiązania, z których skorzystamy

  • utworzymy model widoku dla widoku onboardingu,
  • skorzystamy z mechanizmów programowania reaktywnego,
  • poznasz kontrolkę ViewPager2,
  • ciekawe animacje, które przyciągną użytkownika

Stwórz nowy projekt aplikacji Android w Android Studio. Upewnij się, że w pliku build.gradle projektu znajdują się następujące zależności:

implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core-ktx:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.viewpager2:viewpager2:1.0.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'me.relex:circleindicator:2.1.2'

Zależność androidx.lifecycle:lifecycle-extensions będzie przydatna, w momencie gdy będziemy chcieli skorzystać z listy dostępnych stron onboardingu z modelu widoku. Wkrótce wszystko stanie się jasne. Biblioteka me.relex:circleindicator:2.1.2 posłuży do przedstawienia użytkownikowi postępu onboardingu.

 

Główny widok

Dodaj nowy fragment, który będzie głównym widokiem naszego onboardingu. Nazwij go OnboardingFragment. Dodaj, także layout dla niego i nazwij go fragment_onboarding.xml i umieść w nim poniższą zawartość.

<?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:id="@+id/root_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
       app:layout_constraintBottom_toTopOf="@id/viewPager_indicator"
            app:layout_constraintHeight_percent="0.85"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0"/>

    <me.relex.circleindicator.CircleIndicator3
            android:id="@+id/viewPager_indicator"
            android:layout_width="0dp"
            android:layout_height="48dp"
            android:layout_margin="8dp"
            app:ci_drawable="@drawable/pager_indicator_selected"
            app:ci_drawable_unselected="@drawable/pager_indicator_unselected"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/viewPager"/>
</androidx.constraintlayout.widget.ConstraintLayout>

Standardowy widok. Dodajemy ViewPager, który będzie odpowiedzialny za przewijanie poszczególnych stron, a pod nim korzystamy z bilbioteki CircleIndicator3, która wyświetli nam postęp onboardingu.

Dodajmy trochę logiki do naszego fragmentu. Stwórz klasę OnboardingRepository, która będzie odpowiedzialna za dostarczenie listy z opisem poszczególnych stron onboardingu. Dodatkowo stwórz klasę OnboardingPageItem, która będzie opisem poszczególnej strony. Uznałem, że najlepszym sposobem będzie dostarczenie tu wartości bezpośrednio z zasobów projektu. Dzięki atrybutom przy polach mamy pewność, że się nie pomylimy i dostarczymy odpowiedniego rodzaju zasoby. 

Jeżeli jesteśmy już przy zasobach. Koniecznie pobierz paczkę z zasobami do projektu. https://programistabyc.pl/wpcontent/uploads/ViewPager2_zasoby.zip. Znajdują się w niej pliki graficzne, które zostały stworzone przez Nikita Golubeva.

data class OnboardingPageItem(@StringRes
                              val title: Int,
                              @StringRes
                              val description: Int,
                              @DrawableRes
                              val image: Int)
claclass OnboardingRepository
{
    private val pages = ArrayList<OnboardingPageItem>()

    init
    {
        pages.add(OnboardingPageItem(R.string.onboarding_bar_title, R.string.onboarding_bar_description, R.drawable.ic_bar))
        pages.add(OnboardingPageItem(R.string.onboarding_island_title, R.string.onboarding_island_description, R.drawable.ic_island))
        pages.add(OnboardingPageItem(R.string.onboarding_compass_title, R.string.onboarding_compass_description, R.drawable.ic_compass))
        pages.add(OnboardingPageItem(R.string.onboarding_tickets_title, R.string.onboarding_tickets_description, R.drawable.ic_boarding_pass))
    }

    fun getOnboardingPages(): ArrayList<OnboardingPageItem>
    {
        return pages
    }
}

Dodaj następujące zasoby do strings.xml

<resources>
    <string name="app_name">Onboarding Sample</string>
    <string name="onboarding_bar_title">Order drinks</string>
    <string name="onboarding_bar_description">Order drinks in your application and pickup them in bar</string>
    <string name="onboarding_island_title">Buy your own island</string>
    <string name="onboarding_island_description">Using our app you can buy your own island!</string>
    <string name="onboarding_compass_title">Navigation</string>
    <string name="onboarding_compass_description">Use application compass to navigation</string>
    <string name="onboarding_tickets_description">Buy tickets to cinema, theatre and public events!</string>
    <string name="onboarding_tickets_title">Tickets</string>
</resources>

Świetnie! Teraz stwórzmy model widoku, który pobierze te dane i udostępni je widokowi, gdzie kolejnym krokiem będzie udostępnienie ich dla ViewPager poprzez użycie odpowiedniego adaptera. Uff!

class OnboardingViewModel : ViewModel()
{
    private val onboardingRepository = OnboardingRepository()

    val onboardingPages = MutableLiveData<List<OnboardingPageItem>>()

    init
    {
        var pages = onboardingRepository.getOnboardingPages()
        onboardingPages.postValue(pages)
    }
}

Klasa ta dziedziczy po klasie ViewModel, która pochodzi z komponentów architektonicznych, które w ostatnim czasie promuje Google jako te, które są zalecane podczas tworzenia aplikacji Android. Więcej informacji możesz znaleźć tutaj. Korzystamy z mechanizmu LiveData, który oczywiście jest tutaj nadmiarowy ze względu na prostotę tego rozwiązania, ale chciałem Ci pokazać, że istnieje taki mechanizm.

No dobrze, ale po co mi to?

Myślą główną LiveData jest to, że można obserwować ją z innego miejsca w systemie, a gdy wartość, którą przechowuje, zmieni się, wtedy automatycznie subskrybenci zostaną o tym poinformowani. Tak w skrócie można opisać LiveData.

W metodzie init pobieramy listę stron z repozytorium, a następnie za pomocą metody postValue obiektu LiveData aktualizujemy go. To wszystko, jeżeli chodzi o model widoku.

 

Adapter dla ViewPager

Kolejnym etapem będzie dodanie widoków, które zawrzemy w ViewPagerze. Skorzystamy z fragmentów, które zostaną w nim osadzone, a każdy z nich będzie odpowiedzialny za jedną stronę onboardingu. Dodaj fragment OnboardingPageFragment oraz layout dla niego fragment_onboarding_page.xml

class OnboardingPageFragment : Fragment()
{
    override fun onCreateView(inflater: LayoutInflater,
                              container: ViewGroup?,
                              savedInstanceState: Bundle?): View?
    {
        return inflater.inflate(R.layout.fragment_onboarding_page, container, false)
    }

    override fun onViewCreated(view: View,
                               savedInstanceState: Bundle?)
    {
        super.onViewCreated(view, savedInstanceState)

        arguments?.let {
            onboarding_page_title.text = getString(it.getInt(TITLE))
            onboarding_page_description.text = getString(it.getInt(DESCRIPTION))
            onboarding_page_image.setImageResource(it.getInt(IMAGE))
        }
    }

    companion object
    {
        val IMAGE = "imageId"
        val TITLE = "titleId"
        val DESCRIPTION = "descriptionId"

        fun getInstance(@DrawableRes
                        imageId: Int,
                        @StringRes
                        titleId: Int,
                        @StringRes
                        descriptionId: Int): OnboardingPageFragment
        {
            var fragment = OnboardingPageFragment()
            val args = Bundle()

            args.putInt(IMAGE, imageId)
            args.putInt(TITLE, titleId)
            args.putInt(DESCRIPTION, descriptionId)

            fragment.arguments = args
            return fragment
        }
    }
}
<?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">

    <androidx.appcompat.widget.AppCompatImageView
        android:id="@+id/onboarding_page_image"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_margin="8dp"
        android:scaleType="centerInside"
        app:layout_constraintBottom_toTopOf="@id/onboarding_page_title"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintWidth_percent="0.8"
        app:srcCompat="@drawable/ic_island" />

    <TextView
        android:id="@+id/onboarding_page_title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:gravity="center"
        android:text="TITLE"
        android:textColor="@color/textColor"
        android:textSize="28sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@id/onboarding_page_description"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/onboarding_page_image"
        app:layout_constraintWidth_percent="0.9" />

    <TextView
        android:id="@+id/onboarding_page_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="Description"
        android:textColor="@color/textColor"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.95"
        app:layout_constraintWidth_percent="0.9" />

</androidx.constraintlayout.widget.ConstraintLayout>

Może być dla Ciebie tajemnicą konstrukcja companion object. Można powiedzieć, że to odpowiednik słowa static z innych języków. Popularną praktyką jest korzystanie z companion object do tworzenia nowych instancji fragmenów i inaczej nie jest w tym przypadku. Dodaliśmy metodą getInstance, która przyjmuje jako parametry dane pojedynczej strony onboardingu. Dane te przekazujemy do fragmentu za pomocą obiektu Bundle, który to następnie przypisujemy do właściwości arguments fragmentu. Odbieramy je w metodzie onViewCreated i przypisujemy do odpowiednich kontrolek. 

Voilà! Mamy to!

Ostatni krok

Mamy już prawie wszystko, aby w końcu zobaczyć nasz onboarding. Zaktualizuj OnboardingFragment o następującą treść:

private var viewModel: OnboardingViewModel? = null

override fun onCreateView(inflater: LayoutInflater,
                          container: ViewGroup?,
                          savedInstanceState: Bundle?): View?
{
    return inflater.inflate(R.layout.fragment_onboarding, container, false)
}

override fun onViewCreated(view: View,
                           savedInstanceState: Bundle?)
{
    super.onViewCreated(view, savedInstanceState)

    viewModel = ViewModelProviders.of(this).get(OnboardingViewModel::class.java)

    viewModel?.onboardingPages?.observe(viewLifecycleOwner, Observer { pages ->

        viewPager.adapter = object : FragmentStateAdapter(this)
        {
            override fun createFragment(position: Int): Fragment
            {
                var currentPage = pages.get(position)
                return OnboardingPageFragment.getInstance(currentPage.image, currentPage.title, currentPage.description)
            }

            override fun getItemCount(): Int
            {
                return pages.count()
            }
        }

        viewPager_indicator.setViewPager(viewPager)
    })
}

companion object
{
    fun newInstance(): OnboardingFragment
    {
        return OnboardingFragment()
    }
}

W metodzie onViewCreated tworzymy instancję modelu widoku, korzystając z klasy ViewModelProviders, której zadaniem(zgodnie z nazwą) jest tworzenie nowych modeli widoków. Następnie zaczynamy obserwować onboardingPages pochodzące z modelu widoku. Jako pierwszy argument przekazujemy obiekt viewLifecycleOwner, który reprezenuje cykl życia tego fragmentu. Dzięki temu, kiedy fragment ten nie będzie już dostępny, subskrypcja zostanie usunięta, nie powodując wycieków pamięci.  W ciele tworzymy inline nowy adapter. Przesłaniając dwie metody getItemCount oraz createFragment. Pierwszej nie ma co tłumaczyć, za to druga korzysta z wcześniej zdefiniowanej metody getInstance w OnboardingPageFrament. 

Zaktualizuj MainActivity

Ostatni krok i zobaczymy efekt! W pliku layoutu dodajmy kontener, w którym osadzimy nasz OnboardingFragment.

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <FrameLayout
        android:id="@+id/fragment_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

I zaktualizuj aktywność.

class MainActivity : AppCompatActivity()
{
    override fun onCreate(savedInstanceState: Bundle?)
    {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val onboardingFragment = OnboardingFragment.newInstance()

        supportFragmentManager.beginTransaction()
                .replace(R.id.fragment_container, onboardingFragment, "onboardingFragment")
                .addToBackStack(null)
                .commit()
    }
}

Prawie działa

Funkcjonalnie wszystko powinno być w porządku, ale to jeszcze nie to, co chcieliśmy osiągnąć. Jeżeli wrócisz do początku artykułu, zobaczysz, że podczas przesuwania stron jest tam dodatkowy efekt. Dodajmy go, aby nasz onboarding był bardziej fancy!

PageTransformer działa cuda

Wbrew pozorom nie musimy tworzyć animacji na 1000 linii kodu. ViewPager udostępnia specjalny mechanizm do tego celu, a jest nim PageTransformer. Stwórz nową klasę o nazwie Transformer  i dodaj implementację interfejsu PageTransformer.

class Transformer : ViewPager2.PageTransformer
{
    override fun transformPage(page: View, position: Float)
    {
    }
}

Jego metoda przyjmuje dwa parametry. Pierwszy przyjmuje aktualnie wyświetlaną stronę, natomiast drugi przyjmuje wartość od -1.0 do 1.0, które odzwierciedlają położenie strony. Świetnie nadaje się do animowania poszczególnych stron. Zaktualizuj klasę, dodając następującą zawartość:

class Transformer : ViewPager2.PageTransformer
{
    private val MIN_SIZE = 0.4f

    override fun transformPage(page: View, position: Float)
    {
        if (position >= -1 && position <= 0)
        {
            page.findViewById<View>(R.id.onboarding_page_image).scaleX = getScale(-position)
            page.findViewById<View>(R.id.onboarding_page_image).scaleY = getScale(-position)
        } else if (position > 0)
        {
            page.findViewById<View>(R.id.onboarding_page_image).scaleX = getScale(position)
            page.findViewById<View>(R.id.onboarding_page_image).scaleY = getScale(position)
        }else if (position >= -1 && position <= 1)
        {
            page.findViewById<View>(R.id.onboarding_page_title).translationX = position * page.width / 2
            page.findViewById<View>(R.id.onboarding_page_description).translationX = position * page.width / 5

            page.findViewById<View>(R.id.onboarding_page_title).alpha = 1f - position
            page.findViewById<View>(R.id.onboarding_page_description).alpha = 1f - position
        } else
        {
            page.alpha = position * 1.2f
        }
    }

    private fun getScale(position: Float): Float
    {
        var result = (1f - position) * 1.1f

        if (result > 1f)
            result = 1f
        else if (result < MIN_SIZE)
            result = MIN_SIZE

        return result
    }
}

Wewnątrz metody szukamy odpowiednich widoków za pomocą metody findViewById i na podstawie aktualnej pozycji strony nadajemy odpowiednie animacje. Poeksperymentuj z wartościami, aby zobaczyć jaki wpływ mają na rezultat.

Dodaj następującą linijkę na końcu metody onViewCreated w OnboardingFragment.

viewPager.setPageTransformer(Transformer())

Czekaj na więcej

Wkrótce pojawi się kolejna część, w której pokaże jak jeszcze bardziej udoskonalić nasz onboarding. Do następnego!