[英]Android Data Binding 针对小型视图列表的技巧

703 阅读5分钟
原文链接: medium.com

For Those Little Lists of Views

I was thinking about the previous article in which I wrote about using Android Data Binding with RecyclerView. What if you have a list of elements and don’t really need a RecyclerView to handle it? After all, if you’re only going to show three or four elements on the screen and they are never going to be recycled, there’s no need to bring out the big guns.

Often developers will loop through their entries and create Views manually:

for (Account item : items) {
ItemBinding itemBinding =
ItemBinding.inflate(getLayoutInflater(), parent, true);
itemBinding.setData(item);
}

That’s pretty easy. Wouldn’t it be nice if we could bind to a list in the XML? Something like this would be great:

<LinearLayout
app:entries="@{entries}"
app:layout="@{@layout/item}"
...
/>

Simple List Binding Adapter

I want to use a list of entries to create Views in a LinearLayout and bind those views to the values in the list. Every different layout has its own generated Binding class, so if I want to make a general Binding Adapter, I can’t just call the normal setter. I certainly don’t want to use reflection — it is costly. Instead, just like with RecyclerView, we can use convention to solve the problem.

We will use a convention of having only one variable and that variable is always named some consistent value. No matter what is in the list, the layout will have just one variable with a single name “data.” We can then use the ViewDataBinding.setVariable() method to bind the data in the layout.

@BindingAdapter({"entries", "layout"})
public static <T> void setEntries(ViewGroup viewGroup,
List<T> entries, int layoutId) {
viewGroup.removeAllViews();
if (entries != null) {
LayoutInflater inflater = (LayoutInflater)
viewGroup.getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
for (int i = 0; i < entries.size(); i++) {
T entry = entries.get(i);
ViewDataBinding binding = DataBindingUtil
.inflate(inflater, layoutId, viewGroup, true);
binding.setVariable(BR.data, entry);
}
}
}

And you’d bind it to your ViewGroup like this:

<LinearLayout
app:entries="@{entries}"
app:layout="@{@layout/item}"
...
/>

The above LinearLayout will automatically add children using the item.xml layout with the “data” variable set to the items in entries. This can be used for any ViewGroup in which addView() is enough to manage child Views.

Dynamic Lists

The above Binding Adapter works great for static lists, but what if your list changes on the fly? Perhaps the user has added a new option and that item has to be added to the list of radio buttons. ObservableList gives us the ability to watch for changes and react to them. We use an OnListChangedCallback to observe the changes to the list:

@BindingAdapter({"entries", "layout"})
public static <T> void setEntries(ViewGroup viewGroup,
ObservableList<T> oldEntries, int oldLayoutId,
ObservableList<T> newEntries, int newLayoutId) {
if (oldEntries == newEntries && oldLayoutId == newLayoutId) {
return; // nothing has changed
}

EntryChangeListener listener =
ListenerUtil.getListener(viewGroup, R.id.entryListener);
if (oldEntries != newEntries && listener != null) {
oldEntries.removeOnListChangedCallback(listener);
}

if (newEntries == null) {
viewGroup.removeAllViews();
} else {
if (listener == null) {
listener =
new EntryChangeListener(viewGroup, newLayoutId);
ListenerUtil.trackListener(viewGroup, listener,
R.id.entryListener);
} else {
listener.setLayoutId(newLayoutId);
}
if (newEntries != oldEntries) {
newEntries.addOnListChangedCallback(listener);
}
resetViews(viewGroup, newLayoutId, newEntries);
}
}

There are a few things in the setEntries() Binding Adapter worth noting. First, I use data binding’s feature that lets me get the old values as well as new values. By providing twice as many data parameters as attributes, the first set of parameters receives the old values and the second set receives the new values. I use this to remove the listener from the old entries list.

Second, Android Data Binding normally watches for changes to a list and when a change occurs, it will reevaluate the expression. I want to manage the changes in the Binding Adapter, so it doesn’t do anything when no instance change occurs. I’m using ListenerUtil to track the EntryChangeListener, an OnListChangedCallback. ListenerUtil keeps track of the listener so that it can be retrieved between calls and I use it so that I can remove or modify the old listener and maybe add it to the new list. I need to provide an identifier to use as the key, so I’ve created one:

<resources>
<item type="id" name="entryListener"/>
</resources>

Third, setEntries() relies on EntryChangeListener to update the child Views when there is only data change. Otherwise, it will completely replace the child Views. For example, if the layout ID changes, we scrap the old children and just repopulate the whole thing.

Other than that, it is fairly simple. Here are the other methods that it uses:

private static ViewDataBinding bindLayout(LayoutInflater inflater,
ViewGroup parent, int layoutId, Object entry) {
ViewDataBinding binding = DataBindingUtil.inflate(inflater,
layoutId, parent, false);
binding.setVariable(BR.data, entry);
return binding;
}

private static void resetViews(ViewGroup parent, int layoutId,
List entries) {
parent.removeAllViews();
if (layoutId == 0) {
return;
}
LayoutInflater inflater = (LayoutInflater) parent.getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
for (int i = 0; i < entries.size(); i++) {
Object entry = entries.get(i);
ViewDataBinding binding = bindLayout(inflater, parent,
layoutId, entry);
parent.addView(binding.getRoot());
}
}

Those do basically what was done in the original Binding Adapter. The resetViews() method first removes all the Views from the ViewGroup, then inflates Views and binds the data in the list.

You could have a simple EventChangeListener that just resets the views every time:

private static class EntryChangeListener
extends ObservableList.OnListChangedCallback {
private final ViewGroup mTarget;
private int mLayoutId;

public EntryChangeListener(ViewGroup target, int layoutId) {
mTarget = target;
mLayoutId = layoutId;
}

public void setLayoutId(int layoutId) {
mLayoutId = layoutId;
}

@Override
public void onChanged(ObservableList observableList) {
resetViews(mTarget, mLayoutId, observableList);
}

@Override
public void onItemRangeChanged(ObservableList observableList,
int start, int count) {
resetViews(mTarget, mLayoutId, observableList);
}

@Override
public void onItemRangeInserted(ObservableList observableList,
int start, int count) {
resetViews(mTarget, mLayoutId, observableList);
}

@Override
public void onItemRangeMoved(ObservableList observableList,
int from, int to, int count) {
resetViews(mTarget, mLayoutId, observableList);
}

@Override
public void onItemRangeRemoved(ObservableList observableList,
int start, int count) {
resetViews(mTarget, mLayoutId, observableList);
}
}

To be honest, that’s probably good enough for most use cases. You’re not supposed to use this with large numbers of Views — that’s what RecyclerView is for. However, if I want to animate Views when there is a change, I’ll want to handle the change events better. You might consider something like this for the change listener:

@Override
public void onItemRangeChanged(ObservableList observableList,
int start, int count) {
TransitionManager.beginDelayedTransition(mTarget);
final int end = start + count;
for (int i = start; i < end; i++) {
Object data = observableList.get(i);
View view = mTarget.getChildAt(i);
ViewDataBinding binding = DataBindingUtil.getBinding(view);
binding.setVariable(BR.data, data);
}
}

It just rebinds the data from the current View. Unfortunately, that might work if we have very intelligent transitions, but the default transition doesn’t know what to do when the data changes. Instead, we’ll have to actually replace the Views:

@Override
public void onItemRangeChanged(ObservableList observableList,
int start, int count) {
LayoutInflater inflater = (LayoutInflater) mTarget.getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
TransitionManager.beginDelayedTransition(mTarget);
final int end = start + count;
for (int i = start; i < end; i++) {
Object data = observableList.get(i);
ViewDataBinding binding = bindLayout(inflater,
mTarget, mLayoutId, data);
binding.setVariable(BR.data, observableList.get(i));
mTarget.removeViewAt(i);
mTarget.addView(binding.getRoot(), i);
}
}

Now we see a nice fade-out and fade-in effect of the Views when they change. The rest of the implemented methods are fairly straight-forward using the TransitionManager to animate Views.

@Override
public void onItemRangeInserted(ObservableList observableList,
int start, int count) {
TransitionManager.beginDelayedTransition(mTarget);
final int end = start + count;
LayoutInflater inflater = (LayoutInflater) mTarget.getContext()
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
for ( int i = end - 1; i >= start; i--) {
Object entry = observableList.get(i);
ViewDataBinding binding =
bindLayout(inflater, mTarget, mLayoutId, entry);
mTarget.addView(binding.getRoot(), start);
}
}

@Override
public void onItemRangeMoved(ObservableList observableList,
int from, int to, int count) {
TransitionManager.beginDelayedTransition( mTarget);
for (int i = 0; i < count; i++) {
View view = mTarget.getChildAt(from);
mTarget.removeViewAt(from);
int destination = (from > to) ? to + i : to;
mTarget.addView(view, destination);
}
}

@Override
public void onItemRangeRemoved(ObservableList observableList,
int start, int count) {
TransitionManager.beginDelayedTransition( mTarget);
for (int i = 0; i < count; i++) {
mTarget.removeViewAt(start);
}
}

Considerations

You may be tempted to use the list binding technique instead of RecyclerView. Don’t. Data Binding lists is not a replacement for RecyclerView. Instead, use it to bind to a small set of Views that are all visible within your layout. A good rule of thumb is that if you have to scroll the list, use a RecyclerView. If you don’t, use data binding.

The example I used at first was only four lines of code and I’ve somehow made a Binding Adapter that is nearly 150 lines of code. But now that it’s written, I can use it anywhere in an application to populate my small data-driven UI lists. It will even animate values as it changes and I never have to worry about directly updating the View. And now you don’t have to worry about it, either.

You can see the code in the DataBoundList project here. In that project, users are added and removed from a list and it updates a LinearLayout dynamically. You’d never use this for a list of users because it could easily scroll. That means you should use RecyclerView, but this is just a demo. I hope you find this approach useful for binding lists to ViewGroups in your applications.

 &amp;amp;amp;amp;amp;amp;amp;lt;img class="progressiveMedia-noscript js-progressiveMedia-inner" src="https://cdn-images-1.medium.com/max/800/1*74JuPcuDp8-VfUV3zHzbRw.gif"&amp;amp;amp;amp;amp;amp;amp;gt;
Sample Project Showing Data Bound LinearLayout to an ObservableList