First draft of a fully working ViewGroupAnimator. Tweaked Contact-Editor to use it.
Save focus in More/Less button
Change-Id: I68993f4bc42a3454527832c304f1db48c9b7e1c8
diff --git a/res/layout/item_generic_editor.xml b/res/layout/item_generic_editor.xml
index e672eba..329ad28 100644
--- a/res/layout/item_generic_editor.xml
+++ b/res/layout/item_generic_editor.xml
@@ -48,21 +48,12 @@
android:gravity="center_vertical" />
<ImageButton
- android:id="@+id/edit_more"
+ android:id="@+id/edit_more_or_less"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignBottom="@id/edit_fields"
android:visibility="gone"
- style="@style/MoreButton" />
-
- <ImageButton
- android:id="@+id/edit_less"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentRight="true"
- android:layout_alignBottom="@id/edit_fields"
- android:visibility="gone"
- style="@style/LessButton" />
+ style="@style/EmptyButton" />
</com.android.contacts.ui.widget.GenericEditorView>
diff --git a/res/values/styles.xml b/res/values/styles.xml
index ad4f4f6..93ae91d 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -24,6 +24,10 @@
<item name="android:windowAnimationStyle">@style/ContactsSearchAnimation</item>
</style>
+ <style name="EmptyButton">
+ <item name="android:background">@drawable/btn_circle</item>
+ </style>
+
<style name="MinusButton">
<item name="android:background">@drawable/btn_circle</item>
<item name="android:src">@drawable/ic_btn_round_minus</item>
diff --git a/src/com/android/contacts/ui/widget/GenericEditorView.java b/src/com/android/contacts/ui/widget/GenericEditorView.java
index 7901f60..de4dfae 100644
--- a/src/com/android/contacts/ui/widget/GenericEditorView.java
+++ b/src/com/android/contacts/ui/widget/GenericEditorView.java
@@ -26,6 +26,7 @@
import com.android.contacts.model.ContactsSource.EditType;
import com.android.contacts.model.EntityDelta.ValuesDelta;
import com.android.contacts.ui.ViewIdGenerator;
+import com.android.contacts.util.ViewGroupAnimator;
import com.android.contacts.util.DialogManager;
import com.android.contacts.util.DialogManager.DialogShowingView;
@@ -50,6 +51,7 @@
import android.view.inputmethod.EditorInfo;
import android.widget.ArrayAdapter;
import android.widget.EditText;
+import android.widget.ImageButton;
import android.widget.ListAdapter;
import android.widget.RelativeLayout;
import android.widget.TextView;
@@ -78,8 +80,7 @@
protected TextView mLabel;
protected ViewGroup mFields;
protected View mDelete;
- protected View mMore;
- protected View mLess;
+ protected ImageButton mMoreOrLess;
protected DataKind mKind;
protected ValuesDelta mEntry;
@@ -117,11 +118,8 @@
mDelete = findViewById(R.id.edit_delete);
mDelete.setOnClickListener(this);
- mMore = findViewById(R.id.edit_more);
- mMore.setOnClickListener(this);
-
- mLess = findViewById(R.id.edit_less);
- mLess.setOnClickListener(this);
+ mMoreOrLess = (ImageButton) findViewById(R.id.edit_more_or_less);
+ mMoreOrLess.setOnClickListener(this);
}
protected EditorListener mListener;
@@ -142,8 +140,7 @@
final View v = mFields.getChildAt(pos);
v.setEnabled(enabled);
}
- mMore.setEnabled(enabled);
- mLess.setEnabled(enabled);
+ mMoreOrLess.setEnabled(enabled);
}
/**
@@ -275,14 +272,13 @@
// When hiding fields, place expandable
if (hidePossible) {
- mMore.setVisibility(mHideOptional ? View.VISIBLE : View.GONE);
- mLess.setVisibility(mHideOptional ? View.GONE : View.VISIBLE);
+ mMoreOrLess.setVisibility(View.VISIBLE);
+ mMoreOrLess.setImageResource(
+ mHideOptional ? R.drawable.ic_btn_round_more : R.drawable.ic_btn_round_less);
} else {
- mMore.setVisibility(View.GONE);
- mLess.setVisibility(View.GONE);
+ mMoreOrLess.setVisibility(View.GONE);
}
- mMore.setEnabled(enabled);
- mLess.setEnabled(enabled);
+ mMoreOrLess.setEnabled(enabled);
}
/**
@@ -392,20 +388,42 @@
// Keep around in model, but mark as deleted
mEntry.markDeleted();
- // Remove editor from parent view
- final ViewGroup parent = (ViewGroup)getParent();
- parent.removeView(this);
+ final ViewGroupAnimator animator = ViewGroupAnimator.captureView(getRootView());
+
+ animator.removeView(this);
if (mListener != null) {
// Notify listener when present
mListener.onDeleted(this);
}
+
+ animator.animate();
break;
}
- case R.id.edit_more:
- case R.id.edit_less: {
+ case R.id.edit_more_or_less: {
+ // Save focus
+ final View focusedChild = mFields.getFocusedChild();
+ final int focusedViewId = focusedChild == null ? -1 : focusedChild.getId();
+
+ // Snapshot for animation
+ final ViewGroupAnimator animator = ViewGroupAnimator.captureView(getRootView());
+
+ // Reconfigure GUI
mHideOptional = !mHideOptional;
rebuildValues();
+
+ // Restore focus
+ View newFocusView = mFields.findViewById(focusedViewId);
+ if (newFocusView == null || newFocusView.getVisibility() == GONE) {
+ // find first visible child
+ newFocusView = this;
+ }
+ if (newFocusView != null) {
+ newFocusView.requestFocus();
+ }
+
+ // Animate
+ animator.animate();
break;
}
}
diff --git a/src/com/android/contacts/ui/widget/KindSectionView.java b/src/com/android/contacts/ui/widget/KindSectionView.java
index 221bc16..56ffc8c 100644
--- a/src/com/android/contacts/ui/widget/KindSectionView.java
+++ b/src/com/android/contacts/ui/widget/KindSectionView.java
@@ -24,6 +24,7 @@
import com.android.contacts.model.Editor.EditorListener;
import com.android.contacts.model.EntityDelta.ValuesDelta;
import com.android.contacts.ui.ViewIdGenerator;
+import com.android.contacts.util.ViewGroupAnimator;
import android.content.Context;
import android.provider.ContactsContract.Data;
@@ -201,6 +202,8 @@
if (!mKind.isList)
return;
+ final ViewGroupAnimator animator = ViewGroupAnimator.captureView(getRootView());
+
// Insert a new child and rebuild
final ValuesDelta newValues = EntityModifier.insertChild(mState, mKind);
this.rebuildFromState();
@@ -213,5 +216,7 @@
if (newField != null) {
newField.requestFocus();
}
+
+ animator.animate();
}
}
diff --git a/src/com/android/contacts/util/ViewGroupAnimator.java b/src/com/android/contacts/util/ViewGroupAnimator.java
new file mode 100644
index 0000000..83e8a7d
--- /dev/null
+++ b/src/com/android/contacts/util/ViewGroupAnimator.java
@@ -0,0 +1,476 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.util;
+
+import android.graphics.Rect;
+import android.os.Message;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewRoot;
+import android.view.ViewTreeObserver.OnPreDrawListener;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.Interpolator;
+import android.view.animation.TranslateAnimation;
+import android.view.animation.Animation.AnimationListener;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+
+/**
+ * Automatically configures natural-feeling animations for views. To use this class,
+ * two calls are required:
+ * <ul>
+ * <li>{@link #captureView(View)} takes a snapshot of all the views and their
+ * positions</li>
+ * <li>{@link #animate()} takes another snapshot, calculates the differences
+ * and creates Translate- and FadeAnimations for the changes</li>
+ * </ul>
+ * To match views, the chain of {@link View#getId()} of each View and all of its parents is
+ * compared. It is therefore not necessary to retain object identity between
+ * {@link #captureView(View)} and {@link #animate()}.
+ * This mechanism works fine for Views that are new or moved. To get a fade out effect for deleted
+ * views, one of two approaches have to be done by the consumer:
+ * <ul>
+ * <li>Instead of actually removing the view, it is only hidden (by setting
+ * its visibility to either {@link View#INVISIBLE} or {@link View#GONE})</li>
+ * <li>{@link #removeView(View)} is used to remove the View. This will hide the view during
+ * the animation and actually remove it from its parent, once the animation is finished.</li>
+ * </ul>
+ * The typical usage pattern looks like this:
+ * <pre>
+ * {@code
+ * final ViewGroupAnimator a = ViewGroupAnimator.captureView(view);
+ * // change view here (except for deletions)
+ * a.removeView(someChildViewThatHasToGo);
+ * a.animate();}
+ * </pre>
+ */
+// TODO: If we don't have any FadeOuts, we could save 150ms by starting the other animations sooner
+// TODO: Create an interface containing the normal functions so that we can mock this for tests
+public class ViewGroupAnimator {
+ /* package */ static final String TAG = "ViewAnimator";
+
+ private static final OnPreDrawListener CANCEL_DRAW_LISTENER = new OnPreDrawListener() {
+ public boolean onPreDraw() {
+ return false;
+ }
+ };
+
+ private static int MOVE_DURATION_MILLIS_DEFAULT = 250;
+ private static int FADE_DURATION_MILLIS_DEFAULT = 300;
+
+ private static int FADE_OUT_OFFSET_MILLIS_DEFAULT = 0;
+ private static int MOVE_OFFSET_MILLIS_DEFAULT = 150;
+ private static int FADE_IN_OFFSET_MILLIS_DEFAULT = 400;
+
+ private static final Interpolator INTERPOLATOR = new AccelerateDecelerateInterpolator();
+
+ private final View mRootView;
+ private final Snapshot mBeforeSnapshot;
+ private final HashSet<View> mViewsToRemove = new HashSet<View>();
+
+ private Runnable mOnAnimationsFinished;
+
+ /**
+ * Cancels all pending animations by calling {@link Animation#cancel()} for
+ * all animations that are currently attached to the provided view or any of its children.
+ */
+ public static void cancelRunningAnimations(View view) {
+ final Animation animation = view.getAnimation();
+ if (animation != null) {
+ animation.cancel();
+ view.setAnimation(null);
+ }
+ if (view instanceof ViewGroup) {
+ final ViewGroup viewGroup = (ViewGroup)view;
+ for (int index = 0; index < viewGroup.getChildCount(); index++) {
+ cancelRunningAnimations(viewGroup.getChildAt(index));
+ }
+ }
+ }
+
+ private ViewGroupAnimator(View rootView) {
+ mRootView = rootView;
+ mBeforeSnapshot = buildSnapshot(rootView);
+ cancelRunningAnimations(rootView);
+ }
+
+ /**
+ * Analyses the given view and its children, builds a snapshot and returns an animator
+ * that can later animate changes. This is the only function to get an instance of this class.
+ */
+ public static final ViewGroupAnimator captureView(View rootView) {
+ return new ViewGroupAnimator(rootView);
+ }
+
+ private void setVisibility(Iterable<View> views, int visibility) {
+ for (View view : views) view.setVisibility(visibility);
+ }
+
+ private void forceInstantRelayout() {
+ // This calls a framework internal function to instantly do the layout
+ // TODO: Find an officially supported way once the framework supports it
+
+ final ViewRoot vr = (ViewRoot) mRootView.getParent();
+ // vr can be null when rapidly chaining animations
+ if (vr != null) vr.handleMessage(Message.obtain(null, ViewRoot.DO_TRAVERSAL));
+ }
+
+ private void enableRedraw() {
+ mRootView.getViewTreeObserver().removeOnPreDrawListener(CANCEL_DRAW_LISTENER);
+ }
+
+ private void disableRedraw() {
+ mRootView.getViewTreeObserver().addOnPreDrawListener(CANCEL_DRAW_LISTENER);
+ }
+
+ /**
+ * Sets a function that should be called once all Animations are finished.
+ */
+ public void setOnAnimationsFinished(Runnable runnable) {
+ mOnAnimationsFinished = runnable;
+ }
+
+ /**
+ * Marks a view for deletion. This view will be set to both {@link View#INVISIBLE} and
+ * {@link View#GONE} during measurement and animation and will be removed from its Parent
+ * (using {@link ViewGroup#removeView(View)}) once all Animations are finished.
+ */
+ public void removeView(View view) {
+ mViewsToRemove.add(view);
+ }
+
+ /**
+ * Performs a difference analysis of positions and visibility, configures animations
+ * and starts them.
+ */
+ public void animate() {
+ disableRedraw();
+ try {
+ setVisibility(mViewsToRemove, View.GONE);
+ forceInstantRelayout();
+ final Snapshot currentSnapshot = buildSnapshot(mRootView);
+ final ArrayList<CachedTranslation> translations = new ArrayList<CachedTranslation>();
+ final HashSet<View> goneViews = new HashSet<View>();
+
+ AnimationManager animationManager = new AnimationManager();
+
+ for (String idChain : currentSnapshot.keySet()) {
+ final ViewInfo afterViewInfo = currentSnapshot.get(idChain);
+
+ if (mViewsToRemove.contains(afterViewInfo.getView())) {
+ // There is special handling for these views below
+ continue;
+ }
+
+ final ViewInfo beforeViewInfo = mBeforeSnapshot.get(idChain);
+
+ final boolean isVisible = afterViewInfo.getVisibility() == View.VISIBLE;
+
+ final boolean existedBefore = beforeViewInfo != null;
+ final boolean wasVisible = existedBefore &&
+ beforeViewInfo.getVisibility() == View.VISIBLE;
+
+ if (isVisible && !wasVisible) {
+ // this is a new View ==> fade it in
+ animationManager.doFade(afterViewInfo.getView(),
+ AnimationManager.FADE_TYPE_NEW);
+ continue;
+ } else if (wasVisible && !isVisible) {
+ if (afterViewInfo.getVisibility() == View.GONE) {
+ goneViews.add(afterViewInfo.getView());
+ } else {
+ animationManager.doFade(afterViewInfo.getView(),
+ AnimationManager.FADE_TYPE_VISIBLE_TO_INVISIBLE);
+ }
+ continue;
+ }
+
+ if (isVisible && wasVisible) {
+ // Check if we have to Transform
+ final Rect afterRectangle = afterViewInfo.getRectangle();
+ final Rect beforeRectangle = beforeViewInfo.getRectangle();
+
+ final int diffX = afterRectangle.left - beforeRectangle.left;
+ final int diffY = afterRectangle.top - beforeRectangle.top;
+
+ final boolean doTranslate = diffX != 0 || diffY != 0;
+
+ if (doTranslate) {
+ translations.add(new CachedTranslation(afterViewInfo.getView(),
+ afterRectangle, diffX, diffY));
+ continue;
+ }
+ }
+ }
+
+ // Set views to Invisible, because we need their space for the layout
+ setVisibility(mViewsToRemove, View.INVISIBLE);
+ setVisibility(goneViews, View.INVISIBLE);
+ forceInstantRelayout();
+
+ for (CachedTranslation translation : translations) {
+ final Rect intermediatePosition = translation.getIntermediatePosition();
+ final int addX = intermediatePosition.left - translation.getView().getLeft();
+ final int addY = intermediatePosition.top - translation.getView().getTop();
+ animationManager.doTranslation(translation.getView(),
+ addX - translation.getDiffX(), addX,
+ addY - translation.getDiffY(), addY);
+ }
+
+ for (final View view : mViewsToRemove) {
+ animationManager.doFade(view, AnimationManager.FADE_TYPE_VISIBLE_TO_REMOVED);
+ }
+
+ for (final View view : goneViews) {
+ animationManager.doFade(view, AnimationManager.FADE_TYPE_VISIBLE_TO_GONE);
+ }
+ } finally {
+ enableRedraw();
+ }
+ }
+
+ private static Snapshot buildSnapshot(View rootView) {
+ final Snapshot result = new Snapshot();
+ buildSnapshotRecursive(rootView, result, "");
+ return result;
+ }
+
+ private static void buildSnapshotRecursive(View parentView,
+ Snapshot targetSnapshot, final String parentIdChain) {
+ if (!(parentView instanceof ViewGroup)) return;
+
+ final ViewGroup parentViewGroup = (ViewGroup) parentView;
+ for (int index = 0; index < parentViewGroup.getChildCount(); index++) {
+ final View view = parentViewGroup.getChildAt(index);
+ final int id = view.getId();
+ final String idChain;
+ if (id != View.NO_ID) {
+ idChain = parentIdChain + "/" + id;
+ } else {
+ idChain = parentIdChain + "/i" + index;
+ }
+
+ targetSnapshot.put(idChain, new ViewInfo(view));
+
+ buildSnapshotRecursive(view, targetSnapshot, idChain);
+ }
+ }
+
+ private final class AnimationManager implements AnimationListener {
+ private int mCountCalled = 0;
+
+ private static final int FADE_TYPE_VISIBLE_TO_GONE = 1;
+ private static final int FADE_TYPE_VISIBLE_TO_INVISIBLE = 2;
+ private static final int FADE_TYPE_VISIBLE_TO_REMOVED = 3;
+ private static final int FADE_TYPE_NEW = 4;
+
+ private static final int CLEANUP_NO_ACTION = 0;
+ private static final int CLEANUP_CLEAR_ANIMATION = 1;
+ private static final int CLEANUP_REMOVE = 2;
+ private static final int CLEANUP_SET_TO_GONE = 3;
+
+ private final HashMap<View, AnimationInfo> mAnimations = new HashMap<View, AnimationInfo>();
+
+ public void doTranslation(View view, int fromX, int toX, int fromY, int toY) {
+ final TranslateAnimation animation = new TranslateAnimation(
+ fromX,
+ toX,
+ fromY,
+ toY);
+ animation.setFillBefore(true);
+ animation.setFillAfter(true);
+ animation.setFillEnabled(true);
+ animation.setDuration(MOVE_DURATION_MILLIS_DEFAULT);
+ animation.setStartOffset(MOVE_OFFSET_MILLIS_DEFAULT);
+ animation.setInterpolator(INTERPOLATOR);
+ animation.setAnimationListener(this);
+
+ view.startAnimation(animation);
+
+ mAnimations.put(view, new AnimationInfo(animation, CLEANUP_CLEAR_ANIMATION));
+ }
+
+ public void doFade(View view, int fadeType) {
+ final float fromAlpha = fadeType == FADE_TYPE_NEW ? 0.0f : 1.0f;
+ final float toAlpha = fadeType == FADE_TYPE_NEW ? 1.0f : 0.0f;
+ final AlphaAnimation animation = new AlphaAnimation(fromAlpha, toAlpha);
+ animation.setDuration(FADE_DURATION_MILLIS_DEFAULT);
+ animation.setInterpolator(INTERPOLATOR);
+ animation.setAnimationListener(this);
+
+ final int cleanUpAction;
+ final boolean fill;
+ switch (fadeType) {
+ case FADE_TYPE_NEW:
+ // No clean up necessary
+ cleanUpAction = CLEANUP_NO_ACTION;
+ fill = false;
+ animation.setStartOffset(FADE_IN_OFFSET_MILLIS_DEFAULT);
+ break;
+ case FADE_TYPE_VISIBLE_TO_GONE:
+ cleanUpAction = CLEANUP_SET_TO_GONE;
+ fill = true;
+ animation.setStartOffset(FADE_OUT_OFFSET_MILLIS_DEFAULT);
+ break;
+ case FADE_TYPE_VISIBLE_TO_INVISIBLE:
+ // No clean up necessary
+ cleanUpAction = CLEANUP_NO_ACTION;
+ fill = false;
+ animation.setStartOffset(FADE_OUT_OFFSET_MILLIS_DEFAULT);
+ break;
+ case FADE_TYPE_VISIBLE_TO_REMOVED:
+ cleanUpAction = CLEANUP_REMOVE;
+ fill = true;
+ animation.setStartOffset(FADE_OUT_OFFSET_MILLIS_DEFAULT);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown fadeType");
+ }
+ if (fill) {
+ animation.setFillBefore(true);
+ animation.setFillAfter(true);
+ animation.setFillEnabled(true);
+ }
+ mAnimations.put(view, new AnimationInfo(animation, cleanUpAction));
+
+ view.startAnimation(animation);
+ }
+
+ public void onAnimationEnd(Animation animation) {
+ mCountCalled++;
+ if (mCountCalled == mAnimations.size()) {
+ Log.d(TAG, "Cleaning up animations");
+
+ cleanUp();
+
+ if (mOnAnimationsFinished != null) mOnAnimationsFinished.run();
+ }
+ }
+
+ private void cleanUp() {
+ for (final View view : mAnimations.keySet()) {
+ final AnimationInfo animationInfo = mAnimations.get(view);
+ switch (animationInfo.getCleanUpAction()) {
+ case CLEANUP_NO_ACTION:
+ case CLEANUP_CLEAR_ANIMATION:
+ if (view.getAnimation() != animationInfo.getAnimation()) continue;
+ view.clearAnimation();
+ break;
+ case CLEANUP_REMOVE:
+ final ViewGroup parentGroup = (ViewGroup) view.getParent();
+ // has this view already been removed before?
+ if (parentGroup != null) parentGroup.removeView(view);
+ break;
+ case CLEANUP_SET_TO_GONE:
+ if (view.getAnimation() != animationInfo.getAnimation()) continue;
+ view.clearAnimation();
+ view.setVisibility(View.GONE);
+ break;
+ default:
+ throw new IllegalStateException("Unknown cleanup type");
+ }
+ }
+ }
+
+ public void onAnimationRepeat(Animation animation) {
+ }
+
+ public void onAnimationStart(Animation animation) {
+ }
+ }
+
+ private final static class AnimationInfo {
+ private final Animation mAnimation;
+ private final int mCleanUpAction;
+
+ public Animation getAnimation() {
+ return mAnimation;
+ }
+ public int getCleanUpAction() {
+ return mCleanUpAction;
+ }
+
+ public AnimationInfo(Animation animation, int cleanUpAction) {
+ mAnimation = animation;
+ mCleanUpAction = cleanUpAction;
+ }
+ }
+
+ private final static class ViewInfo {
+ private final View mView;
+ private final Rect mRectangle;
+ private final int mVisibility;
+
+ public Rect getRectangle() {
+ return mRectangle;
+ }
+ public View getView() {
+ return mView;
+ }
+ public int getVisibility() {
+ return mVisibility;
+ }
+
+ public ViewInfo(View view) {
+ mView = view;
+ mRectangle = new Rect(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
+ mVisibility = view.getVisibility();
+ }
+ }
+
+ /**
+ * Shortcut to HashMap<String, ViewInfo>
+ */
+ private final static class Snapshot extends HashMap<String, ViewInfo> {
+
+ }
+
+ private final static class CachedTranslation {
+ private final View mView;
+ private final Rect mIntermediatePosition;
+ private final int mDiffX;
+ private final int mDiffY;
+
+ public View getView() {
+ return mView;
+ }
+
+ public Rect getIntermediatePosition() {
+ return mIntermediatePosition;
+ }
+
+ public int getDiffX() {
+ return mDiffX;
+ }
+
+ public int getDiffY() {
+ return mDiffY;
+ }
+
+ public CachedTranslation(View view, Rect intermediatePosition, int diffX, int diffY) {
+ mView = view;
+ mIntermediatePosition = intermediatePosition;
+ mDiffX = diffX;
+ mDiffY = diffY;
+ }
+ }
+}