feat: use pager-based root tab navigation

This commit is contained in:
kris
2026-04-04 01:09:50 +08:00
parent 05b9bee9e8
commit 31004c512a
8 changed files with 251 additions and 91 deletions

View File

@@ -58,6 +58,7 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.viewpager2:viewpager2:1.1.0"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:4.14.1"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View File

@@ -6,9 +6,10 @@ import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
@@ -19,7 +20,9 @@ import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.viewpager2.widget.ViewPager2;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -60,6 +63,7 @@ public class MainActivity extends AppCompatActivity {
private Button tabConversations;
private Button tabDevices;
private Button tabMe;
private ViewPager2 rootPager;
private SwipeRefreshLayout screenRefresh;
private ScrollView screenScroll;
private LinearLayout screenContent;
@@ -81,7 +85,8 @@ public class MainActivity extends AppCompatActivity {
private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false;
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable GestureDetector conversationPagerGestureDetector;
private @Nullable RootPagerAdapter rootPagerAdapter;
private boolean syncingRootPagerSelection = false;
private final Runnable conversationAutoRefreshRunnable = new Runnable() {
@Override
public void run() {
@@ -175,9 +180,7 @@ public class MainActivity extends AppCompatActivity {
tabConversations = findViewById(R.id.tab_conversations);
tabDevices = findViewById(R.id.tab_devices);
tabMe = findViewById(R.id.tab_me);
screenRefresh = findViewById(R.id.screen_refresh);
screenScroll = findViewById(R.id.screen_scroll);
screenContent = findViewById(R.id.screen_content);
rootPager = findViewById(R.id.root_pager);
String[] rootTabs = WechatSurfaceMapper.rootTabLabels();
tabConversations.setText(rootTabs[0]);
@@ -197,47 +200,23 @@ public class MainActivity extends AppCompatActivity {
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
tabMe.setOnClickListener(v -> setActiveTab("me", true));
screenRefresh.setOnRefreshListener(this::refreshCurrentTab);
conversationPagerGestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
private static final int SWIPE_DISTANCE_THRESHOLD = 120;
private static final int SWIPE_VELOCITY_THRESHOLD = 120;
rootPagerAdapter = new RootPagerAdapter();
rootPager.setAdapter(rootPagerAdapter);
rootPager.setOffscreenPageLimit(3);
rootPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (e1 == null || e2 == null) {
return false;
public void onPageSelected(int position) {
if (syncingRootPagerSelection) {
return;
}
float deltaX = e2.getX() - e1.getX();
float deltaY = e2.getY() - e1.getY();
if (Math.abs(deltaX) < Math.abs(deltaY)) {
return false;
}
if (Math.abs(deltaX) < SWIPE_DISTANCE_THRESHOLD || Math.abs(velocityX) < SWIPE_VELOCITY_THRESHOLD) {
return false;
}
handleHorizontalPageSwipe(deltaX < 0 ? 1 : -1);
return true;
handleRootPagerSelection(position);
}
});
screenRefresh.setOnTouchListener((v, event) -> {
if (conversationPagerGestureDetector != null) {
conversationPagerGestureDetector.onTouchEvent(event);
}
return false;
rootPager.post(() -> {
syncActivePageViews(activeTab);
renderCurrentTab();
updateConversationAutoRefresh();
});
if (screenScroll != null) {
screenScroll.setOnTouchListener((v, event) -> {
if (conversationPagerGestureDetector != null) {
conversationPagerGestureDetector.onTouchEvent(event);
}
return false;
});
}
}
private void applyInitialTab(@Nullable Intent intent) {
@@ -462,6 +441,8 @@ public class MainActivity extends AppCompatActivity {
}
lastRootBackPressedAt = 0L;
updateTabStyles();
syncRootPager(tab, fromUser);
syncActivePageViews(tab);
renderCurrentTab();
updateConversationAutoRefresh();
}
@@ -552,6 +533,9 @@ public class MainActivity extends AppCompatActivity {
}
private void renderConversationsRoot() {
if (screenContent == null) {
return;
}
screenContent.removeAllViews();
screenContent.addView(buildConversationSearchInput());
if (conversationSelectionMode) {
@@ -756,20 +740,14 @@ public class MainActivity extends AppCompatActivity {
});
}
private void handleHorizontalPageSwipe(int direction) {
String[] order = new String[] {"conversations", "devices", "me"};
int currentIndex = 0;
for (int i = 0; i < order.length; i++) {
if (order[i].equals(activeTab)) {
currentIndex = i;
break;
}
}
int nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= order.length) {
private void handleRootPagerSelection(int position) {
String tab = tabForIndex(position);
if (tab.equals(activeTab)) {
syncActivePageViews(tab);
updateConversationAutoRefresh();
return;
}
setActiveTab(order[nextIndex], true);
setActiveTab(tab, true);
}
private EditText buildConversationSearchInput() {
@@ -848,6 +826,9 @@ public class MainActivity extends AppCompatActivity {
}
private void renderDevicesRoot() {
if (screenContent == null) {
return;
}
screenContent.removeAllViews();
if (devicesData == null || devicesData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
@@ -875,6 +856,9 @@ public class MainActivity extends AppCompatActivity {
}
private void renderMeRoot() {
if (screenContent == null) {
return;
}
screenContent.removeAllViews();
String displayName = sessionData == null
? apiClient.getDisplayName()
@@ -945,6 +929,9 @@ public class MainActivity extends AppCompatActivity {
}
private void startRefreshing(boolean refreshing) {
if (screenRefresh == null) {
return;
}
screenRefresh.setRefreshing(refreshing);
syncTopActionVisualState(refreshing);
if (!refreshing) {
@@ -1113,4 +1100,144 @@ public class MainActivity extends AppCompatActivity {
return getSharedPreferences(UI_PREFS, MODE_PRIVATE)
.getString(KEY_LAST_ROOT_TAB, null);
}
private void syncRootPager(String tab, boolean smoothScroll) {
if (rootPager == null) {
return;
}
int targetIndex = indexForTab(tab);
if (targetIndex < 0 || rootPager.getCurrentItem() == targetIndex) {
return;
}
syncingRootPagerSelection = true;
rootPager.setCurrentItem(targetIndex, smoothScroll);
rootPager.post(() -> syncingRootPagerSelection = false);
}
private void syncActivePageViews(String tab) {
if (rootPagerAdapter == null) {
screenRefresh = null;
screenScroll = null;
screenContent = null;
return;
}
RootPageViewHolder holder = rootPagerAdapter.findHolder(tab);
if (holder == null) {
screenRefresh = null;
screenScroll = null;
screenContent = null;
rootPager.post(() -> {
RootPageViewHolder pendingHolder = rootPagerAdapter.findHolder(activeTab);
if (pendingHolder != null) {
screenRefresh = pendingHolder.refresh;
screenScroll = pendingHolder.scroll;
screenContent = pendingHolder.content;
renderCurrentTab();
updateConversationAutoRefresh();
}
});
return;
}
screenRefresh = holder.refresh;
screenScroll = holder.scroll;
screenContent = holder.content;
}
private static int indexForTab(String tab) {
switch (tab) {
case "devices":
return 1;
case "me":
return 2;
case "conversations":
default:
return 0;
}
}
private static String tabForIndex(int index) {
switch (index) {
case 1:
return "devices";
case 2:
return "me";
case 0:
default:
return "conversations";
}
}
private final class RootPagerAdapter extends RecyclerView.Adapter<RootPageViewHolder> {
private final String[] tabs = new String[] {"conversations", "devices", "me"};
private final RootPageViewHolder[] holders = new RootPageViewHolder[tabs.length];
RootPagerAdapter() {
LayoutInflater inflater = LayoutInflater.from(MainActivity.this);
for (int i = 0; i < tabs.length; i++) {
RootPageViewHolder holder = RootPageViewHolder.create(inflater, rootPager, tabs[i]);
holder.refresh.setOnRefreshListener(() -> {
if (!holder.tab.equals(activeTab)) {
setActiveTab(holder.tab, true);
return;
}
refreshCurrentTab();
});
holders[i] = holder;
}
}
@Override
public int getItemCount() {
return tabs.length;
}
@Override
public int getItemViewType(int position) {
return position;
}
@Override
public RootPageViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
RootPageViewHolder holder = holders[viewType];
ViewParent currentParent = holder.itemView.getParent();
if (currentParent instanceof ViewGroup) {
((ViewGroup) currentParent).removeView(holder.itemView);
}
return holder;
}
@Override
public void onBindViewHolder(RootPageViewHolder holder, int position) {
holders[position] = holder;
}
@Nullable
RootPageViewHolder findHolder(String tab) {
int index = indexForTab(tab);
if (index < 0 || index >= holders.length) {
return null;
}
return holders[index];
}
}
private static final class RootPageViewHolder extends RecyclerView.ViewHolder {
final String tab;
final SwipeRefreshLayout refresh;
final ScrollView scroll;
final LinearLayout content;
static RootPageViewHolder create(LayoutInflater inflater, ViewGroup parent, String tab) {
View view = inflater.inflate(R.layout.view_root_tab_page, parent, false);
return new RootPageViewHolder(view, tab);
}
RootPageViewHolder(View itemView, String tab) {
super(itemView);
this.tab = tab;
this.refresh = itemView.findViewById(R.id.root_page_refresh);
this.scroll = itemView.findViewById(R.id.root_page_scroll);
this.content = itemView.findViewById(R.id.root_page_content);
}
}
}

View File

@@ -91,7 +91,7 @@
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="32dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<Button
@@ -142,7 +142,6 @@
android:id="@+id/refresh_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="12dp"
@@ -160,29 +159,10 @@
android:layout_height="0dp"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/root_pager"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/screen_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:layout_height="match_parent" />
</FrameLayout>
<LinearLayout

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_page_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/root_page_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/root_page_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -13,6 +13,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -53,9 +54,11 @@ public class MainActivityConversationSearchTest {
.put("latestReplyLabel", "09:40")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot");
Shadows.shadowOf(activity.getMainLooper()).idle();
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(content.getChildAt(0) instanceof EditText);
EditText input = (EditText) content.getChildAt(0);
assertEquals("搜索项目或线程", String.valueOf(input.getHint()));

View File

@@ -15,6 +15,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -25,23 +26,24 @@ public class MainActivityConversationSelectionTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode"));
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "发起群聊"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-1"));
content = activity.findViewById(R.id.screen_content);
content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "已选 1 个线程"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2"));
content = activity.findViewById(R.id.screen_content);
content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "已选 2 个线程"));
assertFalse(viewTreeContainsText(content, "至少选择 2 个线程"));
}
@@ -51,9 +53,10 @@ public class MainActivityConversationSelectionTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
View row = content.getChildAt(3);
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
}

View File

@@ -14,6 +14,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -40,9 +41,11 @@ public class MainActivityPinnedConversationsTest {
.put("latestReplyLabel", "09:41")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot");
Shadows.shadowOf(activity.getMainLooper()).idle();
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "置顶会话"));
assertTrue(viewTreeContainsText(content, "收起"));
assertTrue(viewTreeContainsText(content, "主 Agent"));

View File

@@ -1,32 +1,51 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import androidx.viewpager2.widget.ViewPager2;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivitySwipeNavigationTest {
@Test
public void horizontalSwipeMovesBetweenRootTabs() {
public void selectingTabsUpdatesPagerAndActiveTab() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ViewPager2 pager = activity.findViewById(R.id.root_pager);
assertNotNull(pager);
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, 1));
activity.findViewById(R.id.tab_devices).performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, pager.getCurrentItem());
assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab"));
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, 1));
activity.findViewById(R.id.tab_me).performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(2, pager.getCurrentItem());
assertEquals("me", ReflectionHelpers.getField(activity, "activeTab"));
}
ReflectionHelpers.callInstanceMethod(activity, "handleHorizontalPageSwipe",
ReflectionHelpers.ClassParameter.from(int.class, -1));
@Test
public void pagerSelectionUpdatesActiveTab() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ViewPager2 pager = activity.findViewById(R.id.root_pager);
assertNotNull(pager);
pager.setCurrentItem(1, false);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals("devices", ReflectionHelpers.getField(activity, "activeTab"));
pager.setCurrentItem(2, false);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals("me", ReflectionHelpers.getField(activity, "activeTab"));
}
}