仿搜狐视频电影海报Gallery效果

搜狐视频的pad版的电影频道(或者电视剧频道)中,海报的切换是幻灯片的方式,当左右滑动选择到一张新的海报时,这张海报会逐渐变大,其实貌似ios的pad版appstore上就有这种效果。个人比较认可这种效果,相对于安卓的Gallery控件(已经不推荐使用)以及后来的ViewPager,横向滚动的ListView,以及网上用RecyclerView实现的各种所谓Gallery,搜狐视频的这个Gallery要有意味的多。

实际上搜狐视频的Gallery并未达到完美模仿appstore的水平,不过已经很接近了。

今天我们就来实现搜狐视频的这种效果。不过,我们也并没有完全实现搜狐视频海报Gallery的所有功能,比如自动播放幻灯片以及循环播放就没有去实现。

思路与方案的选择

在码代码之前,我们需要找到实现的思路。我想到了好几种:自定义ViewPager,修改开源项目CoverFlow ,使用RecyclerView,自定义Gallery控件。其实开源项目CoverFlow 本来就是一个画廊控件,但是其效果是3d倒影效果,滑动的自然程度也不是很理想。我最先尝试了ViewPager的方案,以为最简单,试着做了之后才发现不太好办,因为ViewPager一次只显示一个页面,虽然有一次能显示三页的开源项目,但是最多也只能显示3个。就剩下3种了,一时不太好选择,于是干脆看看搜狐是咋实现的。

反编译

======

我反编译了搜狐视频的源码,虽然搜狐视频并不一定就是用的Gallery控件,但是不管怎样,跟海报效果相关的类了应该也是带了Gallery这个单词的,果不其然,我在反编译的源码中找到了一个GalleryView类。打开一看,其实就是Gallery控件的一个子类。

下面是其经过反编译后的代码:

package com.sohu.sohuvideo.ui.view;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.IntentFilter;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.Gallery;
public class GalleryView extends Gallery
{
  private static final int DEFAULT_INTERVAL = 3000;
  private static final String TAG = "GalleryView";
  private final int FLIP_MSG = 1;
  private boolean mAutoStart = false;
  private int mFlipInterval = 3000;
  private final Handler mHandler = new v(this);
  private final BroadcastReceiver mReceiver = new u(this);
  private boolean mRunning = false;
  private boolean mStarted = false;
  private boolean mTouching = false;
  private boolean mUserPresent = true;
  private boolean mVisible = false;
  public GalleryView(Context paramContext)
  {
    super(paramContext);
  }
  public GalleryView(Context paramContext, AttributeSet paramAttributeSet)
  {
    super(paramContext, paramAttributeSet);
  }
  private void showNext()
  {
    onScroll(null, null, 1.0F, 0.0F);
    onKeyDown(22, null);
  }
  private void updateRunning()
  {
    updateRunning(true);
  }
  private void updateRunning(boolean paramBoolean)
  {
    boolean bool;
    if ((this.mVisible) && (this.mStarted) && (!this.mTouching) && (this.mUserPresent))
    {
      bool = true;
      if (bool != this.mRunning)
      {
        if (!bool)
          break label76;
        Message localMessage = this.mHandler.obtainMessage(1);
        this.mHandler.sendMessageDelayed(localMessage, this.mFlipInterval);
      }
    }
    while (true)
    {
      this.mRunning = bool;
      return;
      bool = false;
      break;
      label76: this.mHandler.removeMessages(1);
    }
  }
  public boolean dispatchTouchEvent(MotionEvent paramMotionEvent)
  {
    if (paramMotionEvent.getAction() == 0)
      if (this.mRunning)
      {
        this.mTouching = true;
        updateRunning(true);
      }
    while (true)
    {
      return super.dispatchTouchEvent(paramMotionEvent);
      if ((paramMotionEvent.getAction() == 2) || (!this.mTouching))
        continue;
      this.mTouching = false;
      updateRunning(true);
    }
  }
  public boolean isAutoStart()
  {
    return this.mAutoStart;
  }
  public boolean isFlipping()
  {
    return this.mStarted;
  }
  protected void onAttachedToWindow()
  {
    super.onAttachedToWindow();
    IntentFilter localIntentFilter = new IntentFilter();
    localIntentFilter.addAction("android.intent.action.SCREEN_OFF");
    localIntentFilter.addAction("android.intent.action.USER_PRESENT");
    getContext().registerReceiver(this.mReceiver, localIntentFilter);
    if (this.mAutoStart)
      startFlipping();
  }
  protected void onDetachedFromWindow()
  {
    super.onDetachedFromWindow();
    this.mVisible = false;
    getContext().unregisterReceiver(this.mReceiver);
    updateRunning(true);
  }
  public boolean onFling(MotionEvent paramMotionEvent1, MotionEvent paramMotionEvent2, float paramFloat1, float paramFloat2)
  {
    if (paramMotionEvent1.getX() - paramMotionEvent2.getX() < 0.0F);
    for (int i = 21; ; i = 22)
    {
      onKeyDown(i, null);
      return true;
    }
  }
  protected void onWindowVisibilityChanged(int paramInt)
  {
    super.onWindowVisibilityChanged(paramInt);
    if (paramInt == 0);
    for (boolean bool = true; ; bool = false)
    {
      this.mVisible = bool;
      updateRunning(false);
      return;
    }
  }
  public void setAutoStart(boolean paramBoolean)
  {
    this.mAutoStart = paramBoolean;
  }
  public void setFlipInterval(int paramInt)
  {
    this.mFlipInterval = paramInt;
  }
  public void startFlipping()
  {
    this.mStarted = true;
    updateRunning(true);
  }
  public void stopFlipping()
  {
    this.mStarted = false;
    updateRunning(true);
  }
}

代码不长,虽然不知道到底做了些什么,但是至少我们已经知道他是用Gallery控件做的。

于是,我也就用Gallery控件做了,从搜狐的代码中,可以看到这里最重要的是重写了onFling方法。

先看看Gallery控件这个方法原本的代码:

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        
        if (!mShouldCallbackDuringFling) {
            // We want to suppress selection changes
            
            // Remove any future code to set mSuppressSelectionChanged = false
            removeCallbacks(mDisableSuppressSelectionChangedRunnable);
            // This will get reset once we scroll into slots
            if (!mSuppressSelectionChanged) mSuppressSelectionChanged = true;
        }
        
        // Fling the gallery!
        mFlingRunnable.startUsingVelocity((int) -velocityX);
        
        return true;
    }

再看看搜狐视频GalleryView中重写了的onFling方法

  public boolean onFling(MotionEvent paramMotionEvent1, MotionEvent paramMotionEvent2, float paramFloat1, float paramFloat2)
  {
    if (paramMotionEvent1.getX() - paramMotionEvent2.getX() < 0.0F);
    for (int i = 21; ; i = 22)
    {
      onKeyDown(i, null);
      return true;
    }
  }

可以看到,GalleryView中,mFlingRunnable.startUsingVelocity((int) -velocityX);没有被调用。而是根据用户的滑动方向主动调用Gallery切换到另一个元素(前一个或者后一个)。然后直接返回,这也就是为什么搜狐视频的Gallery不能一次滑动多张图片的缘故(其实稍微滑动长点还是能滑动几张,但是也没几张,跟默认的比起来少了很多)。第一行显然是判断滑动方向,而里面的for循环应该是混淆导致的结果,原始代码里应该不是for循环。for循环里onKeyDown(i, null)应该就是对应切换到下一个或者上一个的方法了。至于onKeyDown是不是就是如我们猜测的那样的功能,后面会有解释(onKeyDown其实本应由用户产生的按钮事件回调(实体键的手机上),但这里是主动调用。)。

GalleryView其余的代码主要是处理自动循环播放的问题。

纵观整个GalleryView,并没有找到“当左右滑动选择到一张新的海报时,这张海报会逐渐变大”的相关代码。可见这个自定义的GalleryView仅仅是重新定义了onFling的行为。海报的动画效果是在外部实现的。

那么,为什么需要重新定义onFling的行为呢?因为Gallery控件在滑动之后停下来的瞬间,重新调整位置以确保总是有个页面被选中,而这个过程不是很自然,有种顿了一下的感觉,因此干脆屏蔽掉。

于是我自定义了一个如下的Gallery:

/*
 * Copyright (C) 2007 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.example.gallery;
import android.content.Context;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.widget.Gallery;
 
public class GalleryView extends Gallery {
    public GalleryView(Context paramContext){
        super(paramContext);
    }
    
    public GalleryView(Context paramContext, AttributeSet paramAttributeSet){
        super(paramContext, paramAttributeSet);
    }
    
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        if (e1.getX() - e2.getX() < 0.0F){
            onKeyDown(KeyEvent.KEYCODE_DPAD_LEFT,null);
        }else{
            onKeyDown(KeyEvent.KEYCODE_DPAD_RIGHT,null);
        }
        return true;
    }
 
}

onKeyDown(KeyEvent.KEYCODE_DPAD_LEFT,null)可以切换到前一个,onKeyDown(KeyEvent.KEYCODE_DPAD_RIGHT,null)可以切换到后一个。

Gallery控件中onKeyDown方法的源码如下

    /**
     * Handles left, right, and clicking
     * @see android.view.View#onKeyDown
     */
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        switch (keyCode) {
            
        case KeyEvent.KEYCODE_DPAD_LEFT:
            if (movePrevious()) {
                playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT);
                return true;
            }
            break;
        case KeyEvent.KEYCODE_DPAD_RIGHT:
            if (moveNext()) {
                playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT);
                return true;
            }
            break;
        case KeyEvent.KEYCODE_DPAD_CENTER:
        case KeyEvent.KEYCODE_ENTER:
            mReceivedInvokeKeyDown = true;
            // fallthrough to default handling
        }
        
        return super.onKeyDown(keyCode, event);
    }

其中movePrevious()和moveNext()切换前一个和后一个的方法。 为什么我们不在onFling中直接调用movePrevious和moveNext呢?因为他们是私有的方法,无法调用,而onKeyDown虽说本是处理按键事件的的,但这里也可以主动调用他。达到move的效果。

刚刚说了海报的动画效果是在外面实现的,其实具体来说就是在activity中实现的,搜狐视频的反编译代码中我没有找到,只能靠自己了。

其实很简单,就是在Gallery的OnItemSelectedListener中的OnItemSelected回调方法里面,针对当前的item播放属性动画,需要同时播放两个属性动画(scaleY 和 scaleX)才能达到整体变大的效果,其实这招我也是跟 Miroslaw Stanek 学的,他在 InstaMaterial 概念设计的系列文章  中用了很多属性动画来实现很炫的效果,我直接把他的代码copy过来改改参数就可以了。 虽然我早就会使用属性动画,但是并不知道什么样的搭能实现什么样的效果,从Miroslaw Stanek的文章中,我找到了很多实用的方法。

仅仅在有新的item被选中的时候让当前item变大还不行,你还得让那些不再被选中的item变回原来的大小。这也使用属性动画来实现,不过为了达到更好的效果,这次属性动画的持续时间要小点才好。

好了,可以开始写代码了。

开始实现

=======

现在差不多可以开始写代码了:

xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#333333"
  >
    <com.example.gallery.GalleryView android:id="@+id/gallery"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:spacing="5dip"
        android:unselectedAlpha="50"
     /> 
</RelativeLayout>

这里添加了一个上面我们定义的GalleryView控件,而非原生的Gallery控件。

activity中:

package com.example.gallery;
import android.support.v7.app.ActionBarActivity;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.Gallery;
import android.widget.ImageView;
public class GalleryActivity extends ActionBarActivity {
     private int\[\] myImageIds = {R.drawable.photo1, 
             R.drawable.photo2, 
          R.drawable.photo3, 
          R.drawable.photo4, 
          R.drawable.photo5, 
          R.drawable.photo6,R.drawable.photo7};
     int lastSelectedPosition= -1;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_gallery);
        
        Gallery gallery= (Gallery) findViewById(R.id.gallery);
        ImageAdapter imageAdapter = new ImageAdapter(this);
        gallery.setAdapter(imageAdapter);
        gallery.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {  
            @SuppressLint("NewApi")
            @Override  
            public void onItemSelected(AdapterView<?> parent, View v,int position, long id) {  
                AnimatorSet animatorSet = new AnimatorSet();
                ObjectAnimator imgScaleUpYAnim = ObjectAnimator.ofFloat(v, "scaleY", 0.7f, 1f);
                imgScaleUpYAnim.setDuration(600);
                //imgScaleUpYAnim.setInterpolator(DECCELERATE_INTERPOLATOR);
                ObjectAnimator imgScaleUpXAnim = ObjectAnimator.ofFloat(v, "scaleX", 0.7f, 1f);
                imgScaleUpXAnim.setDuration(600);
                animatorSet.playTogether(imgScaleUpYAnim,imgScaleUpXAnim);
                animatorSet.start();
    
                for(int i = 0;i < parent.getChildCount();i++){
                    if(parent.getChildAt(i) != v){
                        View s = parent.getChildAt(i);
                        ObjectAnimator imgScaleDownYAnim = ObjectAnimator.ofFloat(s, "scaleY", 1f, 0.7f);
                        imgScaleDownYAnim.setDuration(100);
                        //imgScaleUpYAnim.setInterpolator(DECCELERATE_INTERPOLATOR);
                        ObjectAnimator imgScaleDownXAnim = ObjectAnimator.ofFloat(s, "scaleX", 1f, 0.7f);
                        imgScaleDownXAnim.setDuration(100);                            
                        animatorSet.playTogether(imgScaleDownXAnim,imgScaleDownYAnim);
                        animatorSet.start();
                    }
                }
            }  
          
            @Override  
            public void onNothingSelected(AdapterView<?> arg0) { 
  
            }  
        }); 
 
    }
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.gallery, menu);
        return true;
    }
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();
        if (id == R.id.action_settings) {
            return true;
        }
        return super.onOptionsItemSelected(item);
    }
    public class ImageAdapter extends BaseAdapter{
 
        private Context mContext;
        public ImageAdapter(Context context){
            mContext = context;
        }
        public int getCount(){
            return myImageIds.length;
        }
        public Object getItem(int position){
            return position;
        }
        public long getItemId(int position){
            return position;
        }
        @SuppressLint("NewApi")
        public View getView(int position, View convertView, ViewGroup parent){
            ImageView imageView = new ImageView(mContext); 
            imageView.setImageResource(myImageIds\[position\]);
            imageView.setScaleType(ImageView.ScaleType.FIT_XY);
            imageView.setLayoutParams(new Gallery.LayoutParams(Gallery.LayoutParams.WRAP_CONTENT, Gallery.LayoutParams.WRAP_CONTENT));
            imageView.setScaleX(0.7f);
            imageView.setScaleY(0.7f);
            return imageView;
        }
    }
    
}

其中当item未被选中时,其缩放比例只有0.7,被选中的时候才回到原始比例。其实经过验证,如果被选中的时候,item是原始大小的1.1背效果更加,因为这样被选中的item(本例中是图片)会稍微超出控件的边界,你可以根据自己的喜好,自己设置,同样item未被选中时,你也可以设置缩放比例为0.6或者0.8.

为了那些不再被选中的item变回原来的大小,我们使用for循环来遍历Gallery的所有子控件,然后每个子控件都缩回0.7的比例

                for(int i = 0;i < parent.getChildCount();i++){
                    if(parent.getChildAt(i) != v){
                        View s = parent.getChildAt(i);
                        ObjectAnimator imgScaleDownYAnim = ObjectAnimator.ofFloat(s, "scaleY", 1f, 0.7f);
                        imgScaleDownYAnim.setDuration(100);
                        //imgScaleUpYAnim.setInterpolator(DECCELERATE_INTERPOLATOR);
                        ObjectAnimator imgScaleDownXAnim = ObjectAnimator.ofFloat(s, "scaleX", 1f, 0.7f);
                        imgScaleDownXAnim.setDuration(100);                            
                        animatorSet.playTogether(imgScaleDownXAnim,imgScaleDownYAnim);
                        animatorSet.start();
                    }
                }

这个地方的实现有点差强人意,在以后的更新中我会找到更妥当的方式。

因为demo中只有几张图片,Gallery的adapter的getView我们没有使用ViewHolder来减少对象的创建,同时注意,在getView中我们也将imageView的缩放大小设置成了0.7,这应该很好理解。

好了现在上效果图:

 

556546546.gif

源码地址:https://github.com/jianghejie/Gallery