Android — ViewPager2 — заменяем фрагменты на лету (программно)

  • Tutorial

Вдруг вам надо листать фрагменты через ViewPager2 и при этом подменять их динамически. Например, чтобы уйти "глубже" - пользователь из фрагмента "Главные настройки" переходит во фрагмент "Выбор языка". При этом новый фрагмент должен отобразиться на месте главного фрагмента. А потом пользователь еще и захочет вернуться обратно...

Дано

  • ViewPager2

  • список Fragment-ов (например, разделы анкеты или многостраничный раздел настроек)

  • Kotlin (Java), Android собственно

Задача

Необходимо заменять фрагменты динамически - на месте n-ого фрагмента должен появится другой. При этом все остальные страницы не должны терять своего состояния.

Пример:

Есть три фрагмента. Первый - список любимых тарелок, второй - экран с сообщениями, третий - настройки.

Пользователь на первом экране выбирает тарелку -> меняем первый фрагмент на фрагмент "Информация о тарелке"

Пользователь листает свайпами или выбирает через TabLayout третий экран - "Настройки", выбирает раздел "Выбор языка" -> меняем третий фрагмент на фрагмент "Выбор языка"

Теперь пользователь, не выходя из выбора языка, листает до первого фрагмента. Он должен увидеть информацию о тарелке, а не список любимых тарелок.

Итак, пытаемся сдружить ArrayMap и ViewPager2.

Результат

Будет такой

Дисклеймер (не читать, вода)

Пишу, потому что найти хороших решений по этой задаче не смог, хотя вроде пытался усердно.

Код местами специально упрощен - не люблю, когда в статьях добавляют десятки строк, не касающихся решения описываемой задачи, только ради соблюдения всех правил. Так что: строковые ресурсы - вынести, вью модели для фрагментов - добавить, пару уровней абстракций - ну вы поняли

Весь проект - https://github.com/IDolgopolov/ViewPager2_FragmentReplacer

Иногда буду использовать слово "страницы" - имею ввиду позицию, на которой отображается фрагмент во ViewPager. Например, "Список любимых тарелок", "Экран сообщений" и "Настройки" - три страницы. Фрагмент "Выбор языка" заменяет третью страницу.

Решение

Сначала ничего интересного, просто для общего ознакомления

Верстка

main_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:orientation="vertical"
    >


    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tab_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/view_pager_2"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity"/>

</LinearLayout>
fragment_one.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Замени меня"
        android:id="@+id/b_replace_fragment_one"
        android:layout_centerInParent="true"
        android:layout_marginHorizontal="20dp"/>

</RelativeLayout>
fragment_deep.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Верни назад, блин"
        android:id="@+id/b_back"
        android:layout_margin="20dp"
        />

</RelativeLayout>
fragment_two.xml, fragment_three.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:text="Фрагмент 3"
    android:gravity="center"
    xmlns:android="http://schemas.android.com/apk/res/android" />

Интерфейсы

Опишем функции, которые нам понадобятся для смены фрагментов

interface FragmentReplacer {
    fun replace(position: Int, newFragment: BaseFragment, isNotify: Boolean = true)
    fun replaceDef(position: Int, isNotify: Boolean = true) : BaseFragment
}

Все фрагменты, добавляемые во ViewPagerAdapter (MyViewPager2Adapter описан ниже), будут наследоваться от BaseFragment.

Определим в нем переменные для хранения:

  • pageId - уникальный номер страницы. Может быть любым числом, но главное - уникальным и большим, чем у вас позиций страниц (pageId > PAGE_COUNT - 1), иначе будут баги из-за метода getItemId()

  • pagePos - номер странице, на которой будет отображаться фрагмент во ViewPager (начиная с 0, естественно)

  • fragmentReplacer - ссылка на ViewPagerAdapter (MyViewPager2Adapter реализует FragmentReplacer)

abstract class BaseFragment(private val layoutId: Int) : Fragment() {
    val pageId = Random.nextLong(2021, 2021*3)
    var pagePos = -1
    protected lateinit var fragmentReplacer: FragmentReplacer

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

    fun setPageInfo(pagePos: Int, fragmentReplacer: FragmentReplacer) {
        this.pagePos = pagePos
        this.fragmentReplacer = fragmentReplacer
    }
}

Связь активити и ViewPager2

class MainActivity : AppCompatActivity() {

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

        //MyViewPager2Adapter - смотреть разбор далее
        view_pager_2.adapter = MyViewPager2Adapter(this)
        TabLayoutMediator(tab_layout, view_pager_2) { tab, position ->
            tab.text = when(position) {
                0 -> "Первый"
                1 -> "Второй"
                2 -> "Крайний"
                else -> throw IllegalStateException()
            }
        }.attach()
    }
}

Адаптер для ViewPager2 - всё здесь

В этом классе будут реализован интерфейс FragmentReplacer и переопределены несколько классов FragmentStateAdapter. Это и позволит менять фрагменты на лету.

Весь код класса, который ниже разбираю подробно
class MyViewPager2Adapter(container: FragmentActivity) : FragmentStateAdapter(container),
    FragmentReplacer {
    
    companion object {
        private const val PAGE_COUNT = 3
    }

    private val mapOfFragment = ArrayMap<Int, BaseFragment>()


    override fun replace(position: Int, newFragment: BaseFragment, isNotify: Boolean) {
        mapOfFragment[position] = newFragment
        if (isNotify)
            notifyItemChanged(position)
    }

    override fun replaceDef(position: Int, isNotify: Boolean): BaseFragment {
        val fragment = when (position) {
            0 -> FragmentOne()
            1 -> FragmentTwo()
            2 -> FragmentThree()
            else -> throw IllegalStateException()
        }
        fragment.setPageInfo(
            pagePos = position,
            fragmentReplacer = this
        )

        replace(position, fragment, isNotify)

        return fragment
    }


    override fun createFragment(position: Int): Fragment {
        return mapOfFragment[position] ?: replaceDef(position, false)
    }

    override fun containsItem(itemId: Long): Boolean {
        var isContains = false
        mapOfFragment.values.forEach {
            if (it.pageId == itemId) {
                isContains = true
                return@forEach
            }
        }
        return isContains
    }

    override fun getItemId(position: Int) =
        mapOfFragment[position]?.pageId ?: super.getItemId(position)

    override fun getItemCount() = PAGE_COUNT
}

В первую очередь переопределим четыре метода FragmentStateAdapter:

//ключ - позиция страницы
//значения - фрагмент, отображаемый в данный момент на этой странице
private val mapOfFragment = ArrayMap<Int, BaseFragment>()

//возвращаем количество страниц (в нашем примере - 3)
override fun getItemCount() = PAGE_COUNT

/*
	эта функция вызывается, когда пользователь впервые открывает страницу
	или был вызван notifyItemChanged(position)
  
  если фрагмент еще не был создан (mapOfFragment[position] == null),
  то заменяем фрагмент на этой странице фрагментов по умолчанию
*/
override fun createFragment(position: Int): Fragment {
		return mapOfFragment[position] ?: replaceDef(position, false)
}

/*
	у каждого нашего фрагмента есть свой id, отдаем его адаптеру
	если фрагмент на этой позиции еще не был создан, можно вернуть любую чипуху, 
	не совпадающую со всеми существующими id
  p.s. super.getItemId { return position } по умолчанию
*/
override fun getItemId(position: Int) =
		mapOfFragment[position]?.pageId ?: super.getItemId(position)


override fun containsItem(itemId: Long): Boolean {
    var isContains = false
       mapOfFragment.values.forEach { if (it.pageId == itemId) {
           isContains = true
           return@forEach
       } }
       return isContains
}

Теперь две функции, которые позволяют подменять фрагменты

override fun replace(position: Int, newFragment: BaseFragment, isNotify: Boolean) {
    //указываем фрагменту, на какой странице он отображается
    newFragment.setPageInfo(
            pagePos = position,
            fragmentReplacer = this
        )
    
    mapOfFragment[position] = newFragment
  	//isNotify = true всегда, кроме вызова replace() из функции createFragment()
  	//иначе приложение будет крашится, так как еще ничего не отображено
    if (isNotify)
    		notifyItemChanged(position)
}

//здесь указываем базовые фрагменты - фрагменты, отображаемые по умолчанию
//пригодится, когда захотим отобразить страницы в первый раз
//или вернуться к дефолтной странице после того, как закончим все дела на замененной
override fun replaceDef(position: Int, isNotify: Boolean): BaseFragment {
    val fragment = when (position) {
            0 -> FragmentOne()
            1 -> FragmentTwo()
            2 -> FragmentThree()
            else -> throw IllegalStateException()
    }

    replace(position, fragment, isNotify)
    return fragment
}

Пример использования

Все готово - у каждого фрагмента есть ссылка на адаптер, значит есть возможность заменить фрагмент на любой странице, а самое главное (наиболее используемое на практике) - есть возмжность заменить самого себя.

Например, фрагмент, по умолчанию, отображаемый на первой странице:

FragmentOne.kt

class FragmentOne : BaseFragment(R.layout.fragment_one) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
				
        //по нажатию кнопки
        b_replace_fragment_one.setOnClickListener {
          	//создаем фрагмент, который заменит этот фрагмент
            val fragmentDeep = FragmentDeep()
					
          	//заменяем 
            fragmentReplacer.replace(this.pagePos, fragmentDeep)
        }
    }
}

FragmentDeep.kt

class FragmentDeep : BaseFragment(R.layout.fragment_deep) {

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

        b_back.setOnClickListener {
          	//по нажатию кнопки заменяем текущий фрагмент 
          	//на дефолтный: 0 -> FragmentOne()
            fragmentReplacer.replaceCurrentToDef()
            
            //или
            //fragmentReplacer.replace(pagePos, FragmentOne())
            
            //или
            //fragmentReplacer.replace(0, FragmentOne())
        }
    }
}

Мысли в слух

Аккуратно, если фрагменты тяжелые, - надо задуматься об очищении mapOfFragment.

Возможно, стоит хранить Class<BaseFragment> вместо BaseFragment. Но тогда придется инициализировать их каждый раз в createFragment(). Меньше памяти, больше времени. Что думаете?

На правах...

dendolg.ru - портфолио, контакты для сотрудничества (после переезда на GithubPages браузер иногда банит https протокол, а иногда не банит - разбираюсь)

t.me/dolgopolovd - блог с нулем подписчиков, в котором рассуждаю о мобильном и промышленном дизайне и иногда отвлекаюсь от программирования

Комментарии 5

    +1

    Зачем хранить сами фрагменты в адаптере? Они ведь и так хранятся в FragmentManager.
    К тому же есть вероятность сломать логику работы FragmentStateAdapter, ведь именно он отвечает за процесс создания и переиспользования фрагментов во ViewPager.
    А что будет при смене конфигурации? Адаптер будет пересоздан с пустым mapOfFragment?
    Бэкстэка на каждой странице не будет, т.е. возврат с "описания тарелки" в "список тарелок" только вручную?

      0
      1. Действительно, возможно стоит хранить фрагменты в FragmentManager, а в mapOfFragments хранить ключи (Fragment.tag например). Нужно подумать над этой реализацией

      2. При смене конфигурации все хорошо, если сохранить mapOfFragments или сам адаптер. Согласен, стоило обратить на это внимание

      3. На каком-то этапе столкнулся с проблемой — действительно понадобился бэкстек. Решение — передавать фрагменту, который открываешь, информацию о том, к какому фрагменту нужно вернуться (ключ или ссылка). А дальше использовать уже существующую функцию replace(position, newFragment)

      Да, это не лучшая логика для использования фрагментов, так делать нельзя в обычной жизни, но при совмещении ViewPager и фрагментов ситуация меняется, приходится прибегать к не самым приятным вещам.

      Спасибо за хорошие вопросы)
      0
      Починил все ссылки. Так и не понял правда, почему хабр без префикса http/https не открывает, но при этом и не сообщает об неправильно введенной.

      Если кто-то понимает, как нормально вставить видео с ютуба в новом редакторе, буду признателен
        0
        abstract class BaseFragment(private val layoutId: Int): Fragment()

        Решение проблемы не вызывает доверия, после того как автор статьи делает у фрагментов конструктор с параметрами без использования фабрики фрагментов и хранит в адаптере «сильные» ссылки на фрагменты.
          0
          конечно, это супер грубое нарушение и за такое надо бить по рукам. по-этому есть раздел «дисклеймер», в котором отметил, что код нарочно упрощен, и все не касающееся именно поставленной задачи отброшено

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое