概述
在 Android 应用程序中,ListView 是一种常用的控件,用于显示可滚动列表数据。然而,当在鼠标操作模式下使用 ListView 时,可能会遇到一个问题:点击列表项时,列表会回滚到指定位置,这可能会导致用户体验不佳。
分析
通过测试发现, 回滚的位置与列表的选中项位置有关系.
在启动activity后, 不执行其他操作时, 通过滚轮直接滚动到列表最下方, 再点击按键, 调用了adapter.notifyDataSetChanged
列表回滚;
dumpsys
dumpsys activity com.android.tester/.cases.ListViewBackground
出现回滚时
View Hierarchy:
DecorView@4ef06ab[ListViewBackground]
com.android.internal.widget.ActionBarOverlayLayout{1102108 V.E...... ........ 0,0-1920,1080 #1020230 android:id/decor_content_parent}
android.widget.FrameLayout{21eb0a1 V.E...... ........ 0,96-1920,1080 #1020002 android:id/content}
android.widget.LinearLayout{e6872c6 V.E...... ........ 0,0-1920,984}
android.widget.ListView{956287 VFED.VC.. .F...... 0,0-1920,984 #7f030002 app:id/lvRight}
android.widget.LinearLayout{f0a45b4 V.E...... ..S..... 0,0-1920,173}
android.widget.TextView{5d4b5dd V.ED..... ..S..... 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{e8f552 VFED..C.. ..S..... 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{37d149e V.E...... ........ 0,175-1920,348}
android.widget.TextView{8be1b7f V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{88c974c VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{d5ba023 V.E...... ........ 0,350-1920,523}
android.widget.TextView{8e00920 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{8ebd2d9 VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{abdc395 V.E...... ........ 0,525-1920,698}
android.widget.TextView{ea1dcaa V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{1a2f09b VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{a91dc38 V.E...... ........ 0,700-1920,873}
android.widget.TextView{c840411 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{3991976 VFED..C.. ....H... 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{535fb77 V.E...... ........ 0,875-1920,1048}
android.widget.TextView{fa483e4 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{f93d04d VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
com.android.internal.widget.ActionBarContainer{3f55702 V.ED..... ........ 0,0-1920,96 #102018e android:id/action_bar_container}
android.widget.Toolbar{d84d813 V.E...... ........ 0,0-1920,96 #102018d android:id/action_bar}
android.widget.TextView{d17fa50 V.ED..... ........ 36,27-122,68}
com.android.internal.widget.ActionBarContextView{b5a2449 G.E...... ......I. 0,0-0,0 #1020192 android:id/action_context_bar}
在启动activity后, 点击列表项(不是BTN所在区域), 通过滚轮直接滚动到列表最下方, 再点击按键, 调用了adapter.notifyDataSetChanged
列表不回滚;
不会滚
View Hierarchy:
DecorView@4ef06ab[ListViewBackground]
com.android.internal.widget.ActionBarOverlayLayout{1102108 V.E...... ........ 0,0-1920,1080 #1020230 android:id/decor_content_parent}
android.widget.FrameLayout{21eb0a1 V.E...... ........ 0,96-1920,1080 #1020002 android:id/content}
android.widget.LinearLayout{e6872c6 V.E...... ........ 0,0-1920,984}
android.widget.ListView{956287 VFED.VC.. .F..H... 0,0-1920,984 #7f030002 app:id/lvRight}
android.widget.LinearLayout{f0a45b4 V.E...... ........ 0,0-1920,173}
android.widget.TextView{5d4b5dd V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{e8f552 VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{d5ba023 V.E...... ........ 0,175-1920,348}
android.widget.TextView{8e00920 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{8ebd2d9 VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{37d149e V.E...... ........ 0,350-1920,523}
android.widget.TextView{8be1b7f V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{88c974c VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{abdc395 V.E...... ........ 0,525-1920,698}
android.widget.TextView{ea1dcaa V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{1a2f09b VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{a91dc38 V.E...... ........ 0,700-1920,873}
android.widget.TextView{c840411 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{3991976 VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
android.widget.LinearLayout{535fb77 V.E...... ........ 0,875-1920,1048}
android.widget.TextView{fa483e4 V.ED..... ........ 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{f93d04d VFED..C.. ........ 1735,0-1920,173 #7f030000 app:id/btn}
com.android.internal.widget.ActionBarContainer{3f55702 V.ED..... ........ 0,0-1920,96 #102018e android:id/action_bar_container}
android.widget.Toolbar{d84d813 V.E...... ........ 0,0-1920,96 #102018d android:id/action_bar}
android.widget.TextView{d17fa50 V.ED..... ........ 36,27-122,68}
com.android.internal.widget.ActionBarContextView{b5a2449 G.E...... ......I. 0,0-0,0 #1020192 android:id/action_context_bar}
View状态输出的源码:
frameworks/base/core/java/android/view/View.java
public String toString() {
StringBuilder out = new StringBuilder(128);
out.append(getClass().getName());
out.append('{');
out.append(Integer.toHexString(System.identityHashCode(this)));
out.append(' ');
switch (mViewFlags&VISIBILITY_MASK) {
case VISIBLE: out.append('V'); break;
case INVISIBLE: out.append('I'); break;
case GONE: out.append('G'); break;
default: out.append('.'); break;
}
out.append((mViewFlags&FOCUSABLE_MASK) == FOCUSABLE ? 'F' : '.');
out.append((mViewFlags&ENABLED_MASK) == ENABLED ? 'E' : '.');
out.append((mViewFlags&DRAW_MASK) == WILL_NOT_DRAW ? '.' : 'D');
out.append((mViewFlags&SCROLLBARS_HORIZONTAL) != 0 ? 'H' : '.');
out.append((mViewFlags&SCROLLBARS_VERTICAL) != 0 ? 'V' : '.');
out.append((mViewFlags&CLICKABLE) != 0 ? 'C' : '.');
out.append((mViewFlags&LONG_CLICKABLE) != 0 ? 'L' : '.');
out.append((mViewFlags&CONTEXT_CLICKABLE) != 0 ? 'X' : '.');
out.append(' ');
out.append((mPrivateFlags&PFLAG_IS_ROOT_NAMESPACE) != 0 ? 'R' : '.');
out.append((mPrivateFlags&PFLAG_FOCUSED) != 0 ? 'F' : '.');
out.append((mPrivateFlags&PFLAG_SELECTED) != 0 ? 'S' : '.');
if ((mPrivateFlags&PFLAG_PREPRESSED) != 0) {
out.append('p');
} else {
out.append((mPrivateFlags&PFLAG_PRESSED) != 0 ? 'P' : '.');
}
out.append((mPrivateFlags&PFLAG_HOVERED) != 0 ? 'H' : '.');
out.append((mPrivateFlags&PFLAG_ACTIVATED) != 0 ? 'A' : '.');
out.append((mPrivateFlags&PFLAG_INVALIDATED) != 0 ? 'I' : '.');
out.append((mPrivateFlags&PFLAG_DIRTY_MASK) != 0 ? 'D' : '.');
out.append(' ');
out.append(mLeft);
out.append(',');
out.append(mTop);
out.append('-');
out.append(mRight);
out.append(',');
out.append(mBottom);
}
从上面的代码可以看出, S代表了View的Selected状态. 通过操作按键的上 下 键改变列表的选中项, 可以看出列表的S项跟随选中项. 而回滚的位置就是选中的位置, 回滚的效果与 setSelection(int pos)相同;
代码中打印ListView.getSelectedItemPosition的返回值, 默认是 0(回滚), 当点击列表项后变为 -1(不回滚)
默认选中从何而来?
打印出setSelected的堆栈信息:
java.lang.Exception: setSelected
at com.android.tester.widgets.XTV.setSelected(XTV.java:23)
at android.view.ViewGroup.dispatchSetSelected(ViewGroup.java:4426)
at android.view.View.setSelected(View.java:22276)
at android.widget.ListView.setupChild(ListView.java:2102)
at android.widget.ListView.makeAndAddView(ListView.java:2055)
at android.widget.ListView.fillDown(ListView.java:786)
at android.widget.ListView.fillFromTop(ListView.java:847)
at android.widget.ListView.layoutChildren(ListView.java:1826)
at android.widget.AbsListView.onLayout(AbsListView.java:2165)
at android.view.View.layout(View.java:20672)
at android.view.ViewGroup.layout(ViewGroup.java:6194)
at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1812)
at android.widget.LinearLayout.layoutHorizontal(LinearLayout.java:1801)
at android.widget.LinearLayout.onLayout(LinearLayout.java:1567)
at android.view.View.layout(View.java:20672)
at android.view.ViewGroup.layout(ViewGroup.java:6194)
at android.widget.FrameLayout.layoutChildren(FrameLayout.java:323)
at android.widget.FrameLayout.onLayout(FrameLayout.java:261)
at android.view.View.layout(View.java:20672)
at android.view.ViewGroup.layout(ViewGroup.java:6194)
at com.android.internal.widget.ActionBarOverlayLayout.onLayout(ActionBarOverlayLayout.java:508)
at android.view.View.layout(View.java:20672)
at android.view.ViewGroup.layout(ViewGroup.java:6194)
at android.widget.FrameLayout.layoutChildren(FrameLayout.java:323)
at android.widget.FrameLayout.onLayout(FrameLayout.java:261)
at com.android.internal.policy.DecorView.onLayout(DecorView.java:753)
at android.view.View.layout(View.java:20672)
at android.view.ViewGroup.layout(ViewGroup.java:6194)
at android.view.ViewRootImpl.performLayout(ViewRootImpl.java:2796)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:2323)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:1462)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:7187)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:949)
at android.view.Choreographer.doCallbacks(Choreographer.java:761)
at android.view.Choreographer.doFrame(Choreographer.java:696)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:935)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:193)
at android.app.ActivityThread.main(ActivityThread.java:6669)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:951)
源码中:
frameworks/base/core/java/android/widget/ListView.java
/**
* Fills the list from pos down to the end of the list view.
*
* @param pos The first position to put in the list
*
* @param nextTop The location where the top of the item associated with pos
* should be drawn
*
* @return The view that is currently selected, if it happens to be in the
* range that we draw.
*/
private View fillDown(int pos, int nextTop) {
View selectedView = null;
int end = (mBottom - mTop);
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
end -= mListPadding.bottom;
}
while (nextTop < end && pos < mItemCount) {
// is this the selected item?
boolean selected = pos == mSelectedPosition;
View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);
nextTop = child.getBottom() + mDividerHeight;
if (selected) {
selectedView = child;
}
pos++;
}
setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
return selectedView;
}
重点看下: mSelectedPosition
/**
* Find a position that can be selected (i.e., is not a separator).
*
* @param position The starting position to look at.
* @param lookDown Whether to look down for other positions.
* @return The next selectable position starting at position and then searching either up or
* down. Returns {@link #INVALID_POSITION} if nothing can be found.
*/
@Override
int lookForSelectablePosition(int position, boolean lookDown) {
final ListAdapter adapter = mAdapter;
if (adapter == null || isInTouchMode()) {
return INVALID_POSITION;
}
final int count = adapter.getCount();
if (!mAreAllItemsSelectable) {
if (lookDown) {
position = Math.max(0, position);
while (position < count && !adapter.isEnabled(position)) {
position++;
}
} else {
position = Math.min(position, count - 1);
while (position >= 0 && !adapter.isEnabled(position)) {
position--;
}
}
}
if (position < 0 || position >= count) {
return INVALID_POSITION;
}
return position;
}
鼠标的操作模式中, isInTouchMode 返回的是false, mSelectedPosition = 0;
当点击了列表项后, isInTouchMode 则返回了true, mSelectedPosition = INVALID_POSITION (-1)
解决回滚
一个取巧的办法: 在鼠标输入的模式下, 会导致View判断isInTouchMode返回false, 那么, 重写该判断方法即可.
package com.android.tester.widgets;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ListView;
public class XLV extends ListView {
public XLV(Context context) {
super(context);
}
public XLV(Context context, AttributeSet attrs) {
super(context, attrs);
}
public XLV(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean isInTouchMode() {
return true;
}
}
设置列表项的背景: android:listSelector 和 android:background 的效果并不相同!
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/red"/>
</shape>
</item>
<item android:state_focused="true">
<shape>
<solid android:color="@color/yellow"/>
</shape>
</item>
<item android:state_checked="true">
<shape>
<solid android:color="@color/cyan"/>
</shape>
</item>
<item android:state_selected="true">
<shape>
<solid android:color="@color/blue"/>
</shape>
</item>
<item android:state_activated="true">
<shape>
<solid android:color="@color/purple"/>
</shape>
</item>
<item>
<shape>
<solid android:color="@color/trans"/>
</shape>
</item>
</selector>
设置列表项的方法有两种, 实际效果却不相同:
android:listSelector 有自己的想法, 测试的代码中, 似乎它对 state_focused (黄色)相当执着, 即使列表项在View的状态已经是Selected, 它依然不变初衷.
从dumpsys可以看到:
android.widget.LinearLayout{9af619f V.E...... ..S..... 0,0-1920,173}
com.android.tester.widgets.XTV{f6532ec V.ED..... ..S..... 0,0-1735,173 #7f030004 app:id/tv}
android.widget.Button{222c6b5 VFED..C.. ..S..... 1735,0-1920,173 #7f030000 app:id/btn}
显示效果:
为列表项单独设置android:background会更灵活一点
//会残留黄色背景
android:listSelector="@null"
//背景干净.
android:listSelector="@color/trans"
修改列表项背景:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:descendantFocusability="blocksDescendants"
android:background="@drawable/selector_item_bg"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.android.tester.widgets.XTV android:id="@+id/tv"
android:layout_width="0dp"
android:layout_weight="1"
android:textColor="#FF987654"
android:gravity="center_vertical"
android:layout_height="match_parent"/>
<Button android:id="@+id/btn"
android:text="BTN"
android:padding="48dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
</LinearLayout>
参考代码
layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--<TextView android:id="@+id/tvLeft"
android:background="@drawable/selector_border_bg"
android:layout_width="0dp"
android:layout_weight="0.3"
android:layout_height="wrap_content"/>-->
<com.android.tester.widgets.XLV
android:id="@+id/lvRight"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:drawSelectorOnTop="false"
android:cacheColorHint="@color/trans"
android:listSelector="@null"
/>
</LinearLayout>
layout item
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:descendantFocusability="blocksDescendants"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.android.tester.widgets.XTV android:id="@+id/tv"
android:layout_width="0dp"
android:layout_weight="1"
android:textColor="#FF987654"
android:gravity="center_vertical"
android:layout_height="match_parent"/>
<Button android:id="@+id/btn"
android:text="BTN"
android:padding="48dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
</LinearLayout>
Activity
package com.android.tester.cases;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.TextView;
import com.android.tester.R;
import java.util.ArrayList;
public class ListViewBackground extends Activity {
final String TAG = "ListViewBackground";
//TextView tvLeft;
ListView lvRight;
LVAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.listview_background);
/*tvLeft = (TextView) findViewById(R.id.tvLeft);
tvLeft.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
lvRight.setSelection(0);
}
});*/
lvRight = (ListView)findViewById(R.id.lvRight);
lvRight.setChoiceMode(AbsListView.CHOICE_MODE_NONE);
adapter = new LVAdapter();
for(int i = 0; i < 50; i ++){
adapter.addData("ITEM " + i);
}
lvRight.setAdapter(adapter);
lvRight.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
/*tvLeft.setText(adapter.content.get(position));
tvLeft.setSelected(position%3 == 0);
tvLeft.setPressed(position%2 == 0);*/
//lvRight.setSelection(position);
}
});
}
class LVAdapter extends BaseAdapter{
ArrayList<Data> content = new ArrayList<>();
void addData(String str){
int pos = content.size();
content.add(new Data());
content.get(pos).content = str;
content.get(pos).pos = pos;
}
@Override
public int getCount() {
return content.size();
}
@Override
public Object getItem(int position) {
return content.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
TAG tag = null;
if(convertView != null && convertView.getTag() != null){
tag = (TAG)convertView.getTag();
}else{
tag = new TAG();
convertView = getLayoutInflater().inflate(R.layout.layout_item_lv_background, null, false);
tag.tv = convertView.findViewById(R.id.tv);
tag.btn = convertView.findViewById(R.id.btn);
tag.btn.setOnClickListener(btnClick);
convertView.setTag(tag);
}
tag.tv.setText(content.get(position).content);
tag.btn.setTag(position);
//convertView.setSelected(content.get(position).selected);
return convertView;
}
View.OnClickListener btnClick = new View.OnClickListener() {
@Override
public void onClick(View v) {
int pos = (int)v.getTag();
//tvLeft.setText("Click Button " + pos);
Log.d(TAG, "onClick isItemChecked=" + lvRight.isItemChecked(pos));
Log.d(TAG, "onClick selected item pos=" + lvRight.getSelectedItemPosition());
//lvRight.setSelection(pos);
//lvRight.setItemChecked(pos, true);
//content.get(pos).selected = true;
notifyDataSetChanged();
}
};
class TAG{
TextView tv;
Button btn;
}
class Data{
int pos;
boolean selected;
String content;
}
}
}
XLV.java
package com.android.tester.widgets;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ListView;
public class XLV extends ListView {
public XLV(Context context) {
super(context);
}
public XLV(Context context, AttributeSet attrs) {
super(context, attrs);
}
public XLV(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean isInTouchMode() {
return true;
}
}
参考
ListView的多选单选模式
listview的属性listselector使用解析