Refactor group tab to ViewPager2 with fragments

Replaces the group tab and server list RecyclerView in MainActivity with a ViewPager2-based implementation using fragments. Adds GroupPagerAdapter and GroupServerFragment for per-group server lists, introduces BaseFragment for fragment view binding, and updates related logic in MainViewModel and SettingsChangeManager. Updates dependencies to include ViewPager2 and Fragment KTX, and modifies layouts to support the new structure.
This commit is contained in:
2dust committed 2026-01-08 13:58:52 +08:00
1 parent 416ee28e80
commit de5b714a28
16 files changed
+240 -99

No files matched your search

+2
View File
@@ -148,6 +148,8 @@ dependencies {
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.fragment)
// UI Libraries
implementation(libs.material)
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class GroupMapItem(
var id: String,
var remarks: String,
)
@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
object SettingsChangeManager {
private val _restartService = MutableStateFlow(false)
private val _reinitGroupTab = MutableStateFlow(false)
private val _setupGroupTab = MutableStateFlow(false)
// Mark restartService as requiring a restart
fun makeRestartService() {
@@ -19,14 +19,14 @@ object SettingsChangeManager {
}
// Mark reinitGroupTab as requiring tab reinitialization
fun makeReinitGroupTab() {
_reinitGroupTab.value = true
fun makeSetupGroupTab() {
_setupGroupTab.value = true
}
// Read and clear the reinitGroupTab flag
fun consumeReinitGroupTab(): Boolean {
val v = _reinitGroupTab.value
_reinitGroupTab.value = false
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
}
@@ -34,12 +34,12 @@ import kotlin.math.sign
*
* @author Paul Burke (ipaulpro)
*/
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
private var mReturnAnimator: ValueAnimator? = null
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
override fun getMovementFlags(
recyclerView: RecyclerView,
@@ -49,10 +49,10 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
val swipeFlags: Int
if (recyclerView.layoutManager is GridLayoutManager) {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
} else {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
}
return makeMovementFlags(dragFlags, swipeFlags)
}
@@ -170,7 +170,7 @@ class BackupActivity : BaseActivity() {
}
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeReinitGroupTab()
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
return count > 0
}
@@ -0,0 +1,53 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.v2ray.ang.helper.CustomDividerItemDecoration
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding: VB
get() = _binding!!
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = inflateBinding(inflater, container)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Adds a custom divider to a RecyclerView.
*
* @param recyclerView The target RecyclerView to which the divider will be added.
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
}
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.v2ray.ang.dto.GroupMapItem
/**
* Pager adapter for subscription groups.
*/
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
@SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) {
this.groups = groups
notifyDataSetChanged()
}
}
@@ -0,0 +1,74 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.viewmodel.MainViewModel
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
companion object {
private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply {
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
}
}
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
FragmentGroupServerBinding.inflate(inflater, container, false)
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(requireActivity() as MainActivity)
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
if (index >= 0) {
adapter.notifyItemChanged(index)
} else {
adapter.notifyDataSetChanged()
}
}
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning
}
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
super.onResume()
mainViewModel.subscriptionIdChanged(subId)
}
}
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
@@ -23,10 +22,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
@@ -37,7 +34,6 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
@@ -51,7 +47,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
ActivityMainBinding.inflate(layoutInflater)
}
private val adapter by lazy { MainRecyclerAdapter(this) }
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
private var tabMediator: TabLayoutMediator? = null
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
startV2Ray()
@@ -61,26 +60,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
restartV2Ray()
}
if (SettingsChangeManager.consumeReinitGroupTab()) {
initGroupTab()
if (SettingsChangeManager.consumeSetupGroupTab()) {
setupGroupTab()
}
}
private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val selectId = tab?.tag.toString()
if (selectId != mainViewModel.subscriptionId) {
mainViewModel.subscriptionIdChanged(selectId)
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
}
private var mItemTouchHelper: ItemTouchHelper? = null
val mainViewModel: MainViewModel by viewModels()
// register activity result for requesting permission
private val requestPermissionLauncher =
@@ -158,17 +141,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(this, 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true
val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
@@ -177,7 +152,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
initGroupTab()
setupGroupTab()
setupViewModel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -198,20 +173,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
})
mainViewModel.reloadServerList()
}
@SuppressLint("NotifyDataSetChanged")
private fun setupViewModel() {
mainViewModel.updateListAction.observe(this) { index ->
if (index >= 0) {
adapter.notifyItemChanged(index)
} else {
adapter.notifyDataSetChanged()
}
}
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning ->
adapter.isRunning = isRunning
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
@@ -230,27 +197,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
mainViewModel.initAssets(assets)
}
private fun initGroupTab() {
binding.tabGroup.removeOnTabSelectedListener(tabGroupListener)
binding.tabGroup.removeAllTabs()
binding.tabGroup.isVisible = false
private fun setupGroupTab() {
val groups = mainViewModel.getSubscriptions(this)
groupPagerAdapter.update(groups)
val (listId, listRemarks) = mainViewModel.getSubscriptions(this)
if (listId == null || listRemarks == null) {
return
}
tabMediator?.detach()
tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position ->
groupPagerAdapter.groups.getOrNull(position)?.let {
tab.text = it.remarks
tab.tag = it.id
}
}.also { it.attach() }
for (it in listRemarks.indices) {
val tab = binding.tabGroup.newTab()
tab.text = listRemarks[it]
tab.tag = listId[it]
binding.tabGroup.addTab(tab)
}
val selectIndex =
listId.indexOf(mainViewModel.subscriptionId).takeIf { it >= 0 } ?: (listId.count() - 1)
binding.tabGroup.selectTab(binding.tabGroup.getTabAt(selectIndex))
binding.tabGroup.addOnTabSelectedListener(tabGroupListener)
binding.tabGroup.isVisible = true
val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1)
binding.viewPager.setCurrentItem(targetIndex, false)
binding.tabGroup.isVisible = groups.size > 1
}
private fun startV2Ray() {
@@ -261,7 +223,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
V2RayServiceManager.startVService(this)
}
public fun restartV2Ray() {
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
}
@@ -271,12 +233,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
public override fun onResume() {
override fun onResume() {
super.onResume()
mainViewModel.reloadServerList()
}
public override fun onPause() {
override fun onPause() {
super.onPause()
}
@@ -475,7 +436,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
mainViewModel.reloadServerList()
}
countSub > 0 -> initGroupTab()
countSub > 0 -> setupGroupTab()
else -> toastError(R.string.toast_failure)
}
binding.pbWaiting.hide()
@@ -693,4 +654,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
}
override fun onDestroy() {
tabMediator?.detach()
super.onDestroy()
}
}
@@ -26,9 +26,7 @@ import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.handler.V2RayServiceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
@@ -31,7 +31,7 @@ class SubEditActivity : BaseActivity() {
setContentView(binding.root)
title = getString(R.string.title_sub_setting)
SettingsChangeManager.makeReinitGroupTab()
SettingsChangeManager.makeSetupGroupTab()
val subItem = MmkvManager.decodeSubscription(editSubId)
if (subItem != null) {
bindingServer(subItem)
@@ -16,6 +16,7 @@ import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
@@ -121,6 +122,7 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
notifyItemRemoved(position)
notifyItemRangeChanged(position, mActivity.subscriptions.size)
mActivity.refreshData()
SettingsChangeManager.makeSetupGroupTab()
}
}
}
@@ -156,6 +158,7 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
override fun onItemMoveCompleted() {
mActivity.refreshData()
SettingsChangeManager.makeSetupGroupTab()
}
override fun onItemDismiss(position: Int) {
@@ -16,6 +16,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -144,7 +145,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun updateCache() {
serversCache.clear()
for (guid in serverList) {
var profile = MmkvManager.decodeServerConfig(guid) ?: continue
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// var profile = MmkvManager.decodeProfileConfig(guid)
// if (profile == null) {
// val config = MmkvManager.decodeServerConfig(guid) ?: continue
@@ -266,22 +267,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @param context The context.
* @return A pair of lists containing the subscription IDs and remarks.
*/
fun getSubscriptions(context: Context): Pair<MutableList<String>?, MutableList<String>?> {
fun getSubscriptions(context: Context): List<GroupMapItem> {
val subscriptions = MmkvManager.decodeSubscriptions()
if (subscriptionId.isNotEmpty()
&& !subscriptions.map { it.first }.contains(subscriptionId)
) {
subscriptionIdChanged("")
}
if (subscriptions.isEmpty()) {
return null to null
}
val listId = subscriptions.map { it.first }.toMutableList()
listId.add(0, "")
val listRemarks = subscriptions.map { it.second.remarks }.toMutableList()
listRemarks.add(0, context.getString(R.string.filter_config_all))
return listId to listRemarks
val groups = mutableListOf<GroupMapItem>()
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
)
)
subscriptions.forEach { (id, item) ->
groups.add(GroupMapItem(id = id, remarks = item.remarks))
}
return groups
}
/**
@@ -48,18 +48,17 @@
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/padding_spacing_dp8"
app:tabIndicatorFullWidth="false"
app:tabMode="scrollable"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:nextFocusRight="@+id/fab"
android:scrollbars="vertical" />
android:scrollbars="vertical"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/layout_test"
@@ -67,7 +66,7 @@
android:layout_height="@dimen/view_height_dp64"
android:clickable="true"
android:focusable="true"
android:nextFocusLeft="@+id/recycler_view"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:orientation="vertical">
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</FrameLayout>
+4
View File
@@ -28,6 +28,8 @@ mockitoMockitoInline = "6.1.0"
flexbox = "3.0.0"
preferenceKtx = "1.2.1"
recyclerview = "1.4.0"
viewpager2 = "1.1.0"
fragment = "1.8.9"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
@@ -62,6 +64,8 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "
flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" }
recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }