InstaMaterial 概念设计(第六部分) - 用户介绍

英文原文:http://frogermcs.github.io/InstaMaterial-concept-part-6-user-profile/

转载此译文需要注明出处

介绍

老实说,如果你仔细阅读了之前的几篇文章,自己尝试过去实现所描述的效果,那么在今天的文章中你学不到什么新的知识。虽然最终的效果看起来非常复杂,但是所用到的技术都是前面讲过的。其实这是个好消息,说明安卓中解决问题的方法是有限的,虽然效果很多,但是都是那些惯用伎俩。真正限制你的不是技术的多少,而是你的想象力。

准备

和往常一样我们首先从添加一些东西开始。我们需要创建一个带有Toolbar,RecyclerView以及FloatingActionButton的UserProfileActivit,因为我们需要使用和inCommentsActivity中相同过渡动画效。我们还要在FeedAdapter中添加跳转到简介照片界面的onClick监听函数。我还顺便用BaseActivity重构了下代码,下面是提交的代码列表:

打不开也没关系,看完整的代码就是了。

UserProfileActivity的circural reveal 过渡动画

Circural reveal

circural reveal动画如上图所示,因为找不到形象的翻译,所以这里直接称为circural reveal动画  - 译者注。

首先从实现用户介绍界面的过渡动画开始。你可能已经注意到,Material设计中介绍了一种圆形动画,主要应用在水波纹的效果中。不幸的是,这种设计上看起来非常nice的效果,在实际应用中没有好的方法可以实现。我们来看看目前可能的几种方案:

  • ViewAnimationUtils.createCircularReveal()

    这是达到circural reveal动画的最简单方法。但是最大的问题是值在Lollipop上有效,并且没有兼容旧设备的库,Ripple 效果所使用的Render线程老设备上没有。

  • CircularReveal library

    这是一个在>2.3设备上实现了ViewAnimationUtils.createCircularReveal()功能的 开源项目 。还比较新,看起来前途无量的样子。它提供了可以动画的两个ViewGroup:RevealFrameLayout 以及 RevealLinearLayout。

  • 也许我们不需要是圆形的?

    好吧,有时候我们并没有太多的时间来寻找最佳的解决方案。如果我们只是想尽快实现一个reveal效果的原型,也许方形的动画也是ok的?。我们可以使用持续时间为30秒的ViewPropertyAnimator来实现这种效果。怎么做呢?只需使用setPivotX/Y 在X和Y方向上缩放就可以了,如下:

    Scale animation

  • 使用shader

    我最喜欢的方法,尤其是当我们有更复杂的View需要动画的时候。Shader对于初学者而言可能难以理解,但是它是实现动画效果最有效率的方法之一。在Romain Guy的博客中,你可以找到利用shader制作reveal动画的代码片段。博客链接

  • 用自己的方式实现

    如果上面的所有方式你都不感冒,那就干脆自己实现。有时候最简单的方法就能满足你的需求。我会告诉你在我们的app中采用的就是这种方案吗。

自定义RevealBackgroundView

我将实现一个可以显示circular reveal动画的自定义view。需求如下:

  • Reveal效果必须是圆形的

  • Reveal动画必须从点击的那个点开始

  • 我们想要掌控动画的状态(未开始,开始了,结束)

  • 我们还要能主动结束动画

在我们的自定义view中我们使用 前面文章 描述的ObjectAnimator。通过设置当前半径值来播放圆形动画。重写 onDraw() 来绘制一个指定半径与圆心的圆或者在动画结束的时候绘制整个view的矩形。下面是RevealBackgroundView的代码:

public class RevealBackgroundView extends View {
    public static final int STATE_NOT_STARTED = 0;
    public static final int STATE_FILL_STARTED = 1;
    public static final int STATE_FINISHED = 2;
    private static final Interpolator INTERPOLATOR = new AccelerateInterpolator();
    private static final int FILL_TIME = 400;
    private int state = STATE_NOT_STARTED;
    private Paint fillPaint;
    private int currentRadius;
    ObjectAnimator revealAnimator;
    private int startLocationX;
    private int startLocationY;
    private OnStateChangeListener onStateChangeListener;
    public RevealBackgroundView(Context context) {
        super(context);
        init();
    }
    public RevealBackgroundView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public RevealBackgroundView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public RevealBackgroundView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init();
    }
    private void init() {
        fillPaint = new Paint();
        fillPaint.setStyle(Paint.Style.FILL);
        fillPaint.setColor(Color.WHITE);
    }
    public void startFromLocation(int\[\] tapLocationOnScreen) {
        changeState(STATE_FILL_STARTED);
        startLocationX = tapLocationOnScreen\[0\];
        startLocationY = tapLocationOnScreen\[1\];
        revealAnimator = ObjectAnimator.ofInt(this, "currentRadius", 0, getWidth() + getHeight()).setDuration(FILL_TIME);
        revealAnimator.setInterpolator(INTERPOLATOR);
        revealAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                changeState(STATE_FINISHED);
            }
        });
        revealAnimator.start();
    }
    public void setToFinishedFrame() {
        changeState(STATE_FINISHED);
        invalidate();
    }
    @Override
    protected void onDraw(Canvas canvas) {
        if (state == STATE_FINISHED) {
            canvas.drawRect(0, 0, getWidth(), getHeight(), fillPaint);
        } else {
            canvas.drawCircle(startLocationX, startLocationY, currentRadius, fillPaint);
        }
    }
    private void changeState(int state) {
        if (this.state == state) {
            return;
        }
        this.state = state;
        if (onStateChangeListener != null) {
            onStateChangeListener.onStateChange(state);
        }
    }
    public void setOnStateChangeListener(OnStateChangeListener onStateChangeListener) {
        this.onStateChangeListener = onStateChangeListener;
    }
    public void setCurrentRadius(int radius) {
        this.currentRadius = radius;
        invalidate();
    }
    public static interface OnStateChangeListener {
        void onStateChange(int state);
    }
}

现在,我们在MainActivity中添加用户介绍界面的开始方法:

@Override
public void onProfileClick(View v) {
    int\[\] startingLocation = new int\[2\];
    v.getLocationOnScreen(startingLocation);
    startingLocation\[0\] += v.getWidth() / 2;
    UserProfileActivity.startUserProfileFromLocation(startingLocation, this);
    overridePendingTransition(0, 0);
}

现在我们有了开始点的位置,只要在UserProfileActivity中使用它并且在恰当的时候(多亏了onPreDrawListener)让背景呈现动画就可以了。UserProfileActivity 的最终代码如下:

public class UserProfileActivity extends BaseActivity implements RevealBackgroundView.OnStateChangeListener {
    public static final String ARG_REVEAL_START_LOCATION = "reveal_start_location";
 
    @InjectView(R.id.vRevealBackground)
    RevealBackgroundView vRevealBackground;
    @InjectView(R.id.rvUserProfile)
    RecyclerView rvUserProfile;
 
    private UserProfileAdapter userPhotosAdapter;
 
    public static void startUserProfileFromLocation(int\[\] startingLocation, Activity startingActivity) {
        Intent intent = new Intent(startingActivity, UserProfileActivity.class);
        intent.putExtra(ARG_REVEAL_START_LOCATION, startingLocation);
        startingActivity.startActivity(intent);
    }
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_user_profile);
        setupUserProfileGrid();
        setupRevealBackground(savedInstanceState);
    }
 
    private void setupRevealBackground(Bundle savedInstanceState) {
        vRevealBackground.setOnStateChangeListener(this);
        if (savedInstanceState == null) {
            final int\[\] startingLocation = getIntent().getIntArrayExtra(ARG_REVEAL_START_LOCATION);
            vRevealBackground.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                @Override
                public boolean onPreDraw() {
                    vRevealBackground.getViewTreeObserver().removeOnPreDrawListener(this);
                    vRevealBackground.startFromLocation(startingLocation);
                    return false;
                }
            });
        } else {
            userPhotosAdapter.setLockedAnimations(true);
            vRevealBackground.setToFinishedFrame();
        }
    }
 
    private void setupUserProfileGrid() {
        final StaggeredGridLayoutManager layoutManager = new StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL);
        rvUserProfile.setLayoutManager(layoutManager);
        rvUserProfile.setOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                userPhotosAdapter.setLockedAnimations(true);
            }
        });
    }
 
    @Override
    public void onStateChange(int state) {
        if (RevealBackgroundView.STATE_FINISHED == state) {
            rvUserProfile.setVisibility(View.VISIBLE);
            userPhotosAdapter = new UserProfileAdapter(this);
            rvUserProfile.setAdapter(userPhotosAdapter);
        } else {
            rvUserProfile.setVisibility(View.INVISIBLE);
        }
    }
}

这里我做了些什么呢?如果Activity开始,运行reveal动画。否则,如果是Activity重新恢复,则调用vRevealBackground.setToFinishedFrame()将背景状态设置为结束状态。用户简介与RecyclerView在reveal动画播放的时候是隐藏的,当状态变为STATE_FINISHED的时候(这是在onStateChange()方法中完成的)则显示。userPhotosAdapter.setLockedAnimations(true)的作用是让动画在滚动列表以及恢复Activity的时候不可用。

用户介绍的布局

用户简介可以被分成3个主要部分:

  • 显示用户数据信息的头部

  • 用户介绍的菜单选项

  • 用户的照片列表

首先为每部分准备布局,其实没什么特别之处,下面是截图。如果你需要可以试着自己去实现,也可以在 这个 commit 中获取源码。

用户介绍 的头部

User profile header

用户介绍的选项

User profile options

用户照片

User photo

接下来我们去实现UserProfileAdapter。用户介绍可以以一个RecyclerView呈现出来。头部和选项卡需要占据整个宽度,图片在它们之下,一行3个元素。这就是我们使用StaggeredGridLayoutManager的原因,它可以定义一行包括多少个元素,不仅如此,它还可以让我们计算出一个item需要占据的空间大小。

让我们来建立我们的adapter,首先定义Item的类型:

UserProfileAdapter_types.java

public static final int TYPE_PROFILE_HEADER = 0;
public static final int TYPE_PROFILE_OPTIONS = 1;
public static final int TYPE_PHOTO = 2;
 
@Override
public int getItemViewType(int position) {
    if (position == 0) {
        return TYPE_PROFILE_HEADER;
    } else if (position == 1) {
        return TYPE_PROFILE_OPTIONS;
    } else {
        return TYPE_PHOTO;
    }
}

每个类型都需要它自己的ViewHolder:

UserProfileAdapter_holders.java

static class ProfileHeaderViewHolder extends RecyclerView.ViewHolder {
    @InjectView(R.id.ivUserProfilePhoto)
    ImageView ivUserProfilePhoto;
    @InjectView(R.id.vUserDetails)
    View vUserDetails;
    @InjectView(R.id.btnFollow)
    Button btnFollow;
    @InjectView(R.id.vUserStats)
    View vUserStats;
    @InjectView(R.id.vUserProfileRoot)
    View vUserProfileRoot;
 
    public ProfileHeaderViewHolder(View view) {
        super(view);
        ButterKnife.inject(this, view);
    }
}
 
static class ProfileOptionsViewHolder extends RecyclerView.ViewHolder {
    @InjectView(R.id.btnGrid)
    ImageButton btnGrid;
    @InjectView(R.id.btnList)
    ImageButton btnList;
    @InjectView(R.id.btnMap)
    ImageButton btnMap;
    @InjectView(R.id.btnTagged)
    ImageButton btnComments;
    @InjectView(R.id.vUnderline)
    View vUnderline;
    @InjectView(R.id.vButtons)
    View vButtons;
 
    public ProfileOptionsViewHolder(View view) {
        super(view);
        ButterKnife.inject(this, view);
    }
}
 
static class PhotoViewHolder extends RecyclerView.ViewHolder {
    @InjectView(R.id.flRoot)
    FrameLayout flRoot;
    @InjectView(R.id.ivPhoto)
    ImageView ivPhoto;
 
    public PhotoViewHolder(View view) {
        super(view);
        ButterKnife.inject(this, view);
    }
}

我们在 onCreateViewHolder() 方法中使用它们,在这里我们需要inflate view,将它们设置给layout manager:

UserProfileAdapter_createholders.java

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    if (TYPE_PROFILE_HEADER == viewType) {
        final View view = LayoutInflater.from(context).inflate(R.layout.view_user_profile_header, parent, false);
        StaggeredGridLayoutManager.LayoutParams layoutParams = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
        layoutParams.setFullSpan(true);
        view.setLayoutParams(layoutParams);
        return new ProfileHeaderViewHolder(view);
    } else if (TYPE_PROFILE_OPTIONS == viewType) {
        final View view = LayoutInflater.from(context).inflate(R.layout.view_user_profile_options, parent, false);
        StaggeredGridLayoutManager.LayoutParams layoutParams = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
        layoutParams.setFullSpan(true);
        view.setLayoutParams(layoutParams);
        return new ProfileOptionsViewHolder(view);
    } else if (TYPE_PHOTO == viewType) {
        final View view = LayoutInflater.from(context).inflate(R.layout.item_photo, parent, false);
        StaggeredGridLayoutManager.LayoutParams layoutParams = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
        layoutParams.height = cellSize;
        layoutParams.width = cellSize;
        layoutParams.setFullSpan(false);
        view.setLayoutParams(layoutParams);
        return new PhotoViewHolder(view);
    }
 
    return null;
}

在6,12以及20行,我们使用 setFullSpan()来指定哪个view应该占据整行宽度。
最后一件事是(动画之前的最后一件事)绑定ViewHolder

UserProfileAdapter_bindholders.java

@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
    int viewType = getItemViewType(position);
    if (TYPE_PROFILE_HEADER == viewType) {
        bindProfileHeader((ProfileHeaderViewHolder) holder);
    } else if (TYPE_PROFILE_OPTIONS == viewType) {
        bindProfileOptions((ProfileOptionsViewHolder) holder);
    } else if (TYPE_PHOTO == viewType) {
        bindPhoto((PhotoViewHolder) holder, position);
    }
}

我们不会关注 bind..方法。。。不过有件事值得提醒一下。在加载图片的时候我们使用了Picasso 库。它有个很好的功能,可以在图像被放到ImageView之前在后台转换图像。

圆形用户头像

你可能注意到,用户的头像有一个白色圆形的边框。这种效果可以通过Picasso的图像转换(image transformation)功能结合shader实现。我们只需要实现修改指定bitmap的Transformation接口就可以了,至于圆形的实现我们使用shader。(可以参考另一个 Romain Guy的代码片段 ,关于shader使用的简单例子 )

代码简单:

CircleTransformation.java

public class CircleTransformation implements Transformation {
 
    private static final int STROKE_WIDTH = 6;
 
    @Override
    public Bitmap transform(Bitmap source) {
        int size = Math.min(source.getWidth(), source.getHeight());
 
        int x = (source.getWidth() - size) / 2;
        int y = (source.getHeight() - size) / 2;
 
        Bitmap squaredBitmap = Bitmap.createBitmap(source, x, y, size, size);
        if (squaredBitmap != source) {
            source.recycle();
        }
 
        Bitmap bitmap = Bitmap.createBitmap(size, size, source.getConfig());
 
        Canvas canvas = new Canvas(bitmap);
 
        Paint avatarPaint = new Paint();
        BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
        avatarPaint.setShader(shader);
 
        Paint outlinePaint = new Paint();
        outlinePaint.setColor(Color.WHITE);
        outlinePaint.setStyle(Paint.Style.STROKE);
        outlinePaint.setStrokeWidth(STROKE_WIDTH);
        outlinePaint.setAntiAlias(true);
 
        float r = size / 2f;
        canvas.drawCircle(r, r, r, avatarPaint);
        canvas.drawCircle(r, r, r - STROKE_WIDTH / 2, outlinePaint);
 
        squaredBitmap.recycle();
        return bitmap;
    }
 
    @Override
    public String key() {
        return "circleTransformation()";
    }
}

CircularTransformation 绘制了一个表面有白色边框的圆形bitmap。

目前为止,上面描述的UserProfileAdapter的完整代码在这里。下面是目前实现的最终效果:

User profile

用户介绍的进入动画

User profile animation

最后一件事-进入动画。在概念视频中看起来貌似有点复杂,但是我认为这是今天文章的最简单部分。实际上我们只需要用ViewPropertyAnimator对每个view在恰当的时机使用持续时间恰当的动画就可以了。

头部和选项卡的动画需要立即播放,因此最佳的时机是在PreDrawListener的回调之中。动画很简单,只是改变view的translation和alpha属性。

所有的代码不过20行:

UserProfileAdapter_headeranimations.java

private void animateUserProfileHeader(ProfileHeaderViewHolder viewHolder) {
    if (!lockedAnimations) {
        profileHeaderAnimationStartTime = System.currentTimeMillis();
 
        viewHolder.vUserProfileRoot.setTranslationY(-viewHolder.vUserProfileRoot.getHeight());
        viewHolder.ivUserProfilePhoto.setTranslationY(-viewHolder.ivUserProfilePhoto.getHeight());
        viewHolder.vUserDetails.setTranslationY(-viewHolder.vUserDetails.getHeight());
        viewHolder.vUserStats.setAlpha(0);
 
        viewHolder.vUserProfileRoot.animate().translationY(0).setDuration(300).setInterpolator(INTERPOLATOR);
        viewHolder.ivUserProfilePhoto.animate().translationY(0).setDuration(300).setStartDelay(100).setInterpolator(INTERPOLATOR);
        viewHolder.vUserDetails.animate().translationY(0).setDuration(300).setStartDelay(200).setInterpolator(INTERPOLATOR);
        viewHolder.vUserStats.animate().alpha(1).setDuration(200).setStartDelay(400).setInterpolator(INTERPOLATOR).start();
    }
}
 
private void animateUserProfileOptions(ProfileOptionsViewHolder viewHolder) {
    if (!lockedAnimations) {
        viewHolder.vButtons.setTranslationY(-viewHolder.vButtons.getHeight());
        viewHolder.vUnderline.setScaleX(0);
 
        viewHolder.vButtons.animate().translationY(0).setDuration(300).setStartDelay(USER_OPTIONS_ANIMATION_DELAY).setInterpolator(INTERPOLATOR);
        viewHolder.vUnderline.animate().scaleX(1).setDuration(200).setStartDelay(USER_OPTIONS_ANIMATION_DELAY + 300).setInterpolator(INTERPOLATOR).start();
    }
}

正如我所说的,最重要的是开始的合适时机,这里我们是使用反复实验来决定。

不过图片列表的动画少有不同,我们不知道从网络上加载它们需要多长时间,因此运行动画的最佳地方是图片显示的时候。幸运的是Picasso有一个回调接口,里面有onSuccess() and onError()方法,我们将在里面触发动画。

我还需要考虑第二种情况-当图片加载太快,在简介的进入动画还没完成之前就加载完了,因此我们需要计算出一个延迟动画的值。
最终的代码如下:

UserProfileAdapter_photoanimation.java

private void bindPhoto(final PhotoViewHolder holder, int position) {
    Picasso.with(context)
            .load(photos.get(position - MIN_ITEMS_COUNT))
            .resize(cellSize, cellSize)
            .centerCrop()
            .into(holder.ivPhoto, new Callback() {
                @Override
                public void onSuccess() {
                    animatePhoto(holder);
                }
 
                @Override
                public void onError() {
 
                }
            });
    if (lastAnimatedItem < position) lastAnimatedItem = position;
}
 
private void animatePhoto(PhotoViewHolder viewHolder) {
    if (!lockedAnimations) {
        if (lastAnimatedItem == viewHolder.getPosition()) {
            setLockedAnimations(true);
        }
 
        long animationDelay = profileHeaderAnimationStartTime + MAX_PHOTO_ANIMATION_DELAY - System.currentTimeMillis();
        if (profileHeaderAnimationStartTime == 0) {
            animationDelay = viewHolder.getPosition() * 30 + MAX_PHOTO_ANIMATION_DELAY;
        } else if (animationDelay < 0) {
            animationDelay = viewHolder.getPosition() * 30;
        } else {
            animationDelay += viewHolder.getPosition() * 30;
        }
 
        viewHolder.flRoot.setScaleY(0);
        viewHolder.flRoot.setScaleX(0);
        viewHolder.flRoot.animate()
                .scaleY(1)
                .scaleX(1)
                .setDuration(200)
                .setInterpolator(INTERPOLATOR)
                .setStartDelay(animationDelay)
                .start();
    }
}

最后,这次更改的所有代码都在这里

以上是今天的全部内容。我们完成了用户介绍的打开动画以及其他的一些动画,谢谢阅读!

源代码

项目的完整代码在这里:Github repository

作者: Miroslaw Stanek

来自:InstaMaterial概念设计