APP启动引导图

我们知道,基本上每个 APP 都会有启动引导图,就是启动 APP 时能够左右滑动的大图,滑动到最后一页时,再左滑或是点击“进入”按钮,才进到首页(通常引导图只会显示一次,即显示过就不再显示了)。
同样的,基本每个 APP 首页也都会有幻灯大图,可以左右滑动,或每个几秒自动滚动。而引导图跟幻灯实现起来其实很类似,闲着没事,使用 ViewPager 实现了一下此功能。工程源码在这里:https://github.com/JulyDev/AppGuide

最终效果:

app_guide.gif

Talk is cheap, show you the code.

工程结构

image.png

其中 FirstActivity是启动 Activity, MainActivity 模拟的是首页, WelcomeGuideActivity 就是引导页啦。启动 APP 时,首先会打开 FirstActivity,然后是进到首页,在首页先判断引导图是不是显示过,若没显示过则先展示引导图(引导图一般只显示一次,若清除数据或重新安装APP则会重新显示引导图),引导图展示完毕回到首页,逻辑就是这么简单。

FirstAcitivity代码很简单:

public class FirstActivity extends Activity
{
    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_first);
        // 根据需要,做些初始化操作
        // init();
        // 模拟跳转MainActivity时机
        new Handler().postDelayed(new Runnable()
        {
            @Override
            public void run()
            {
                startActivity(new Intent(FirstActivity.this, MainActivity.class));
                finish();
            }
        }, 1000);

    }
}

显示引导页的逻辑放在了MainActivity里:

/**
 * 首页
 */
public class MainActivity extends Activity
{

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 如果没有显示过引导图,则显示之(为了方便查看效果,此处把判断条件注释掉了)
        // if (ConfigUtil.needShowGuide(this))
        {
            startActivity(new Intent(this, WelcomeGuideActivity.class));
        }
        // 首页其他部分该怎么显示就怎么显示
        // ……
    }
}

下面重点看一下引导图页面的实现逻辑。

引导图实现逻辑

启动引导图一般要求可以左右滑动(用 ViewPager 就能实现啦),右上角有“跳过”字样,点击就直接进到首页,不再展示剩下的引导图了。最后一页引导图一般会有一个进入 APP 的按钮,点击即可关闭引导图,进入到首页。
另外,引导图下方一般都会有圆点点,表示引导图个数,并突出显示当前所在图片的位置。这些点点的实现方式有两种,一是切图时让设计直接切在图片上,二是自己手动去实现。我通过自定义 View 来实现的(PonitView)。
在此基础上,我又增加了两个功能:

  1. 滑动到最后一页时,继续滑动,也能进入首页,且是平滑过渡,不会显得那么突兀;
  2. 做了View的缓存,可以减少内存的占用。

其实就引导图而言,这个缓存可有可无,因为引导图个数一般不会太多张,而缓存对于超过三张的图片才会有效果。不过为了记录知识点,我还是加了缓存策略,这样以后做首页幻灯那种效果也是可以拿来直接使用的,哇哈哈。

public class WelcomeGuideActivity extends Activity
{
    private static final String TAG = "WelcomeGuideActivity";
    /**
     * 引导图个数
     */
    private static final int COUNTS = 4;
    /**
     * View 最大缓存个数
     */
    private static final int MAX_CACHE_COUNT = 3;

    private ViewPager viewPager;

    /**
     * View缓存,考虑view的复用,只需要三个view就够了
     */
    private ArrayList<View> viewList = new ArrayList<View>(MAX_CACHE_COUNT);

    private GuideAdapter adapter;

    /**
     * 当前在第几个图片
     */
    private int currentPosition;

    /**
     * 引导图下方的点点,会突出显示当前滑动到第几个
     */
    private PointView pointView;

    // 本地图片id
    private int[] resIds = {R.mipmap.guide1, R.mipmap.guide2, R.mipmap.guide3,R.mipmap.guide4};

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_welcome_guide);
        initViews();
    }

    private void initViews()
    {
        viewList.clear();
        for (int i = 0; i < MAX_CACHE_COUNT; i++)
        {
            View pageView = View.inflate(this, R.layout.welcome_guide_view, null);
            ViewHolder holder = new ViewHolder();
            holder.image = (ImageView) pageView.findViewById(R.id.guide_image);
            holder.skip = (TextView) pageView.findViewById(R.id.skip);
            holder.entry = (ImageView) pageView.findViewById(R.id.use_at_once);
            pageView.setTag(holder);
            viewList.add(pageView);
        }
        viewPager = (ViewPager) findViewById(R.id.guide_viewpager);
        adapter = new GuideAdapter();
        viewPager.setAdapter(adapter);
        // 为 1 的时候可以不用手动设置了,默认就是 1
        // viewPager.setOffscreenPageLimit(1);
        viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener()
        {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
            {

            }

            @Override
            public void onPageSelected(int position)
            {
                currentPosition = position;
                pointView.setSelectedPosition(position);
                Log.d(TAG, " onPageSelected position = " + position);
            }

            @Override
            public void onPageScrollStateChanged(int state)
            {

            }
        });
        viewPager.setOnTouchListener(new View.OnTouchListener()
        {
            float startX, endX;

            @Override
            public boolean onTouch(View v, MotionEvent event)
            {
                switch (event.getAction())
                {
                    case MotionEvent.ACTION_DOWN:
                        startX = event.getX();
                        break;
                    case MotionEvent.ACTION_UP:
                        try
                        {
                            endX = event.getX();

                            // 首先要确定的是,是否到了最后一页,然后判断是否向左滑动,并且滑动距离是否大于某段距离,这里的判断距离是屏幕宽度的四分之一(可以适当控制)
                            if (currentPosition == (COUNTS - 1)
                                    && (startX - endX) >= (screenWidthPx(WelcomeGuideActivity.this) / 4))
                            {
                                enterMainActivity();
                            }
                        }
                        catch (Exception e)
                        {
                            Log.e("Exception", e + "");
                        }
                        break;
                }
                return false;
            }
        });
        // 添加点点
        pointView = (PointView) findViewById(R.id.point_view);
        pointView.addPoints(COUNTS);
        pointView.setSelectedPosition(0);
    }

    class GuideAdapter extends PagerAdapter
    {
        @Override
        public Object instantiateItem(ViewGroup container, int position)
        {
            View view = createItemView(position);
            container.removeView(view);
            container.addView(view);
            Log.d(TAG, " instantiateItem position = " + position + ",view pos = " + position % MAX_CACHE_COUNT + ",container size = " + container.getChildCount());
            return view;
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object)
        {
            // 不在此处删除(在此处删除,显示可能会有问题),在instantiateItem里addView前删除
            // container.removeView(viewList.get(position % MAX_CACHE_COUNT));
            Log.d(TAG, " destroyItem position = " + position);
        }

        @Override
        public int getCount()
        {
            return COUNTS;
        }

        @Override
        public boolean isViewFromObject(View view, Object object)
        {
            return view == object;
        }
    }

    /**
     * ViewPager 每一页View
     * 
     * @param position
     * @return
     */
    private View createItemView(int position)
    {
        if (position >= COUNTS || position < 0)
        {
            return null;
        }
    //  注意这里要取缓存列表里的View,所以position范围只能是0,1,2,取模即可
        int pos = position % MAX_CACHE_COUNT;
        View view = viewList.get(pos);
        ViewHolder holder = (ViewHolder) view.getTag();
        holder.image.setImageResource(resIds[position]);
        View useAtOnce = holder.entry;
        View skip = holder.skip;
        skip.setOnClickListener(new View.OnClickListener()
        {
            @Override
            public void onClick(View v)
            {
                enterMainActivity();
            }
        });
        if (position < COUNTS - 1)
        {
            // 只显示右上角"跳过"
            useAtOnce.setVisibility(View.GONE);
            skip.setVisibility(View.VISIBLE);
        }
        else if (position == COUNTS - 1)
        {
            // 最后一页
            useAtOnce.setVisibility(View.VISIBLE);
            skip.setVisibility(View.GONE);
        }

        useAtOnce.setOnClickListener(new View.OnClickListener()
        {
            @Override
            public void onClick(View v)
            {
                enterMainActivity();
            }
        });
        return view;
    }

    /**
     * 关闭引导界面,进入首页
     */
    private void enterMainActivity()
    {
        finish();
    }

    /**
     * 小的为屏幕宽度
     * 
     * @param context
     * @return
     */
    public static int screenWidthPx(Context context)
    {
        int widthPx = context.getResources().getDisplayMetrics().widthPixels;
        int heightPx = context.getResources().getDisplayMetrics().heightPixels;
        return widthPx > heightPx ? heightPx : widthPx;
    }

    private static class ViewHolder
    {
        /**
         * 引导图
         */
        public ImageView image;

        /**
         * 跳过
         */
        public TextView skip;

        /**
         * 立即使用按钮
         */
        public ImageView entry;
    }
}

下面说一下实现过程中,需要注意的地方:

  1. 滑动到最后一页时,继续滑动,也能进入首页,且是平滑过渡,不会显得那么突兀;
    首先,重写ViewPager的setOnTouchListener,代码往上翻……
    然后,给Activity加切换动画,我是通过设置 Activity 的主题的方式来实现的,加一个右进左出的动画就可以了。在 AndroidManifest.xml里设置如下:
      <!-- 首页 -->
        <activity
            android:name="com.july.welcomeguide.MainActivity"
            android:configChanges="keyboardHidden|orientation|screenSize"
            android:screenOrientation="portrait"
            android:theme="@style/RightInLeftOutTheme"
            android:windowSoftInputMode="adjustPan">
        </activity>
        <!--App启动引导界面-->
        <activity
            android:name="com.july.welcomeguide.WelcomeGuideActivity"
            android:configChanges="keyboardHidden|orientation|screenSize"
            android:screenOrientation="portrait"
            android:theme="@style/RightInLeftOutTheme"
            android:windowSoftInputMode="adjustPan" >
            </activity>

其中 RightInLeftOutTheme 是这样子的:

<style name="RightInLeftOutTheme" parent="@android:style/Theme.NoTitleBar">
        <item name="android:windowAnimationStyle">@style/RightInLeftOutAnimation</item>
    </style>

    <!-- 右进左出动画-->
    <style name="RightInLeftOutAnimation" parent="@android:style/Animation">
        <item name="android:activityOpenEnterAnimation">@anim/slide_right_in</item>
        <item name="android:activityOpenExitAnimation">@anim/slide_left_out</item>
        <item name="android:activityCloseEnterAnimation">@anim/slide_right_in</item>
        <item name="android:activityCloseExitAnimation">@anim/slide_left_out</item>
    </style>
  1. 关于 View 缓存遇到的坑
    我们知道,ViewPager 有个setOffscreenPageLimit(int limit) 方法,源码定义如下:
/**
     * Set the number of pages that should be retained to either side of the
     * current page in the view hierarchy in an idle state. Pages beyond this
     * limit will be recreated from the adapter when needed.
     *
     * <p>This is offered as an optimization. If you know in advance the number
     * of pages you will need to support or have lazy-loading mechanisms in place
     * on your pages, tweaking this setting can have benefits in perceived smoothness
     * of paging animations and interaction. If you have a small number of pages (3-4)
     * that you can keep active all at once, less time will be spent in layout for
     * newly created view subtrees as the user pages back and forth.</p>
     *
     * <p>You should keep this limit low, especially if your pages have complex layouts.
     * This setting defaults to 1.</p>
     *
     * @param limit How many pages will be kept offscreen in an idle state.
     */
    public void setOffscreenPageLimit(int limit) {
        if (limit < DEFAULT_OFFSCREEN_PAGES) {
            Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to "
                    + DEFAULT_OFFSCREEN_PAGES);
            limit = DEFAULT_OFFSCREEN_PAGES;
        }
        if (limit != mOffscreenPageLimit) {
            mOffscreenPageLimit = limit;
            populate();
        }
    }

意思大概就是说我们可以设置在空闲状态的视图层次结构中,应该保留在当前页的任意一侧的页面数,不手动设置的话,默认的就是1,也就是保留当前页(左)右两侧各一个。调整这个值,能够优化页面切换的流畅度,如果页面个数比较少的话(3-4)也可以不用缓存,把页面全部创建出来并保持激活状态,这样前后切换创建新布局的耗时更少。
如上述所言,针对引导图比较少的情况,View 可以不用缓存,即有多少页面就创建多少个View,这个很简单。加了缓存逻辑也没什么坏处,也方便以后的扩展。

  • 坑一
    View缓存的个数最大就是3个,这个一定要跟引导图的总个数别搞混了,如果COUNTS == MAX_CACHE_COUNT ,就相当于没做缓存。
/**
     * 引导图个数
     */
    private static final int COUNTS = 4;

    private static final int MAX_CACHE_COUNT = 3;

    private ViewPager viewPager;

    /**
     * View缓存,考虑view的复用,只需要三个view就够了
     */
    private ArrayList<View> viewList = new ArrayList<View>(MAX_CACHE_COUNT);

//此处省略n行代码
……

private void initViews()
    {
        viewList.clear();
        for (int i = 0; i < MAX_CACHE_COUNT; i++)
        {
            View pageView = View.inflate(this, R.layout.welcome_guide_view, null);
            ViewHolder holder = new ViewHolder();
            holder.image = (ImageView) pageView.findViewById(R.id.guide_image);
            holder.skip = (TextView) pageView.findViewById(R.id.skip);
            holder.entry = (ImageView) pageView.findViewById(R.id.use_at_once);
            pageView.setTag(holder);
            viewList.add(pageView);
        }
//此处省略n行代码
……
}

-坑二
因为使用了缓存View,所以不能在destroyItem里去移除老的 View,在引导图超过3个时,移除时会导致页面闪动,而且显示错乱。解决方法就是在instantiateItem()方法里在 container.addView(view);之前,调用 container.removeView(view);就可以了。

class GuideAdapter extends PagerAdapter
    {
        @Override
        public Object instantiateItem(ViewGroup container, int position)
        {
            View view = createItemView(position);
            container.removeView(view);
            container.addView(view);
            Log.d(TAG, " instantiateItem position = " + position + ",view pos = " + position % MAX_CACHE_COUNT + ",container size = " + container.getChildCount());
            return view;
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object)
        {
            // 不在此处删除(在此处删除,显示可能会有问题),在instantiateItem里addView前删除
            // container.removeView(viewList.get(position % MAX_CACHE_COUNT));
            Log.d(TAG, " destroyItem position = " + position);
        }
……
}

3.自定义点点
直接上代码吧:

public class PointView extends LinearLayout {
    public PointView(Context context) {
        this(context, null);
    }

    public PointView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PointView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        setOrientation(HORIZONTAL);
        setGravity(Gravity.CENTER);
    }

    /**
     * 设置当前选中的点点位置
     * @param position
     */
    public void setSelectedPosition(int position)
    {
        int count = getChildCount();
        for (int i = 0; i < count; i++)
        {
            getChildAt(i).setEnabled(i == position);
        }
    }

    /**
     * 添加点点(外部调用接口)
     * @param size
     */
    public void addPoints(int size)
    {
        addPointBtn(size, R.drawable.point_btn_bg, 8, 8, 16);
    }

    /**
     * 添加点点
     * @param size 点点个数
     * @param imageId
     * @param width 单位dp
     * @param height 单位dp
     * @param margin 单位dp
     */
    private void addPointBtn(int size, int imageId, int width, int height, int margin)
    {
        removeAllViews();
        if (size <= 0)
        {
            return;
        }
        ImageView imageView;
        for (int i = 0; i < size; i++)
        {
            imageView = new ImageView(getContext());

            imageView.setBackgroundResource(imageId);
            imageView.setEnabled(false);
            addView(imageView, ConvertUtil.dip2px(getContext(), width), ConvertUtil.dip2px(getContext(), height));

            LinearLayout.LayoutParams params = (LayoutParams) imageView.getLayoutParams();
            if(i == size - 1)
            {
                params.setMargins(0, 0, 0, 0);
            }
            else
            {
                params.setMargins(0, 0, ConvertUtil.dip2px(getContext(), margin), 0);
            }
        }
    }
}

好了,就说这些吧,如果再发现什么问题再补充吧。或者大家的火眼金睛发现了问题,也欢迎留言提出来,大家一起学习。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,448评论 25 707
  • W:复习药理两章,完成经络背诵 O:完成学习任务顺利完成考试,自己做其他事情时会更加安心 O:时间分配问题,支教队...
    高N少女阅读 105评论 0 0
  • 现如今,有很多的学生上了大学突然不知道做什么了,曾经的高三忙着理所应当、天经地义,只为了高考后能上个好大学;而上了...
    遇见阿文阅读 2,526评论 1 5
  • 周末朋友相约带着孩子去乡下摘核桃。 我们开车,很快来到了成都附近的龙泉山脚下。 朋友的父母在这里修了一栋两层楼的农...
    香香草阅读 449评论 0 3