自定义View高仿懂球帝我是教练效果

前言

这几天不少欧洲球队来中国进行热身赛,不知道喜欢足球的各位小伙伴们有没有看球。喜欢足球的朋友可能知道懂球帝APP,鄙人也常用这个应用,里面有一个我是教练的功能挺好玩,就是能够模拟教练员的身份,排兵布阵;本着好奇心简单模仿了一下,在这里和你们分享。java

效果图

老规矩,先上效果图看看模仿的像不。android

add_player
add_player

move_player
move_player

玩过我是教练这个功能的小伙伴能够对比一下。git

总的来讲,这样的一个效果,其实很简单,就是一个view随着手指在屏幕上移动的效果,外加一个图片替换的动画。但就是这些看似简单的效果,在实现的过程当中也是遇到了不少坑,涨了许多新姿式。好了,废话不说,代码走起(。◕ˇ∀ˇ◕)。github

自定义View-BallGameView

整个内容中最核心的就是一个自定义View-BallGameView,就是屏幕中绿色背景,有气泡和球员图片的整个view。canvas

说到自定义View,老生常谈,你们一直都在学习,却永远都以为本身没有学会,可是自定义View的知识原本就不少呀,想要熟练掌握,必须假以时日数组

既然是自定View就从你们最关心的两个方法 onMeasure和onDraw 两个方法提及。这里因为是纯粹继承自View,就不考虑onLayout的实现了。bash

测量-onMeasure

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int viewW = screenW;
        int viewH = (int) (screenW * 1.3);
        setMeasuredDimension(viewW, viewH);
    }复制代码

这里onMeasure()方法的实现很简单,简单的用屏幕的宽度规定了整个View 的宽高;至于1.3这个倍数,彻底一个估算值,没必要深究。app

绘制-onDraw

onDraw()方法是整个View中最核心的方法。ide

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);
        //绘制提示文字透明背景
        canvas.drawRoundRect(mRoundRect, 8, 8, mRectPaint);
        //绘制底部提示文字 ( TextPiant 文字垂直居中实现 http://blog.csdn.net/hursing/article/details/18703599)
        Paint.FontMetricsInt fontMetrics = mTipPaint.getFontMetricsInt();
        float baseY=(mRoundRect.bottom+mRoundRect.top)/2-(fontMetrics.top+fontMetrics.bottom)/2;
        canvas.drawText(tips, screenW / 2, baseY, mTipPaint);


        //绘制初始的11个气泡
        for (int i = 0; i < players.length; i++) {
            //绘制当前选中的球员
            if (i == currentPos) {

                if (players[i].isSetReal()) {
                    //绘制球员头像
                    canvas.drawBitmap(players[i].getBitmap(), positions[i].x - playW / 2,
                            positions[i].y - playW / 2, mPaint);
                    //绘制选中球员金色底座
                    canvas.drawBitmap(playSelectedBitmap, positions[i].x - goldW / 2,
                            positions[i].y - goldH / 2, mPaint);

                    //绘制球员姓名
                    canvas.drawText(players[i].getName(), positions[i].x,
                            positions[i].y + playW, mTextPaint);

                } else {
                    canvas.drawBitmap(selectedBitmap, positions[i].x - playW / 2,
                            positions[i].y - playW / 2, mPaint);
                }


            } else {
                canvas.drawBitmap(players[i].getBitmap(), positions[i].x - playW / 2,
                        positions[i].y - playW / 2, mPaint);
                if (players[i].isSetReal()) {

                    //绘制球员姓名
                    canvas.drawText(players[i].getName(), positions[i].x,
                            positions[i].y + playW, mTextPaint);
                    //绘制已设置正常图片球员背景
                    canvas.drawBitmap(playeBgBitmap, positions[i].x - grayW / 2,
                            positions[i].y + 200, mPaint);
                }
            }
        }
    }复制代码

能够看到,在onDraw方法里,咱们主要使用了canvas.drawBitmap 方法,绘制了不少图片。下面就简单了解一下canvas.drawBitmap 里的两个重载方法。布局

  • drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint)
/** * Draw the specified bitmap, scaling/translating automatically to fill * the destination rectangle. If the source rectangle is not null, it * specifies the subset of the bitmap to draw. * * * @param bitmap The bitmap to be drawn * @param src May be null. The subset of the bitmap to be drawn * @param dst The rectangle that the bitmap will be scaled/translated * to fit into * @param paint May be null. The paint used to draw the bitmap */
    public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst, @Nullable Paint paint) {

    }复制代码

drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint),这个重载方法主要是经过两个Rectangle 决定了bitmap以怎样的形式绘制出来。简单来讲,src 这个长方形决定了“截取”bitmap的大小,dst 决定了最终绘制出来时Bitmap应该占有的大小。。就拿上面的代码来讲

backgroundBitmap = BitmapFactory.decodeResource(res, R.drawable.battle_bg);
        //确保整张背景图,都能完整的显示出来
        bitmapRect = new Rect(0, 0, backgroundBitmap.getWidth(), backgroundBitmap.getHeight());
        //目标区域,在整个视图的大小中,绘制Bitmap
        mViewRect = new Rect(0, 0, viewW, viewH);
        //绘制背景
        canvas.drawBitmap(backgroundBitmap, bitmapRect, mViewRect, mPaint);复制代码

bitmapRect 是整个backgroundBitmap的大小,mViewRect也就是咱们在onMeasure里规定的整个视图的大小,这样至关于把battle_bg这张图片,以scaleType="fitXY"的形式画在了视图大小的区域内。这样,你应该理解这个重载方法的含义了。

  • drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
/**
     * Draw the specified bitmap, with its top/left corner at (x,y), using
     * the specified paint, transformed by the current matrix.
     *
     *
     * @param bitmap The bitmap to be drawn
     * @param left   The position of the left side of the bitmap being drawn
     * @param top    The position of the top side of the bitmap being drawn
     * @param paint  The paint used to draw the bitmap (may be null)
     */
    public void drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint) {

    }复制代码

这个重载方法应该很容易理解了,left,top 规定了绘制Bitmap的左上角的坐标,而后按照其大小正常绘制便可。

这里咱们全部的气泡(球员位置)都是使用这个方法绘制的。足球场上有11个球员,所以咱们经过数组预先定义了11个气泡的初始位置,而后经过其坐标位置,绘制他们。为了绘制精确,须要减去每张图片自身的宽高,这应该是很传统的作法了。

同时,在以后的触摸反馈机制中,咱们会根据手指的滑动,修改这些坐标值,这样就能够随意移动球员在场上的位置了;具体实现,结合代码中的注释应该很容易理解了,就再也不赘述;能够查看完整源码BallGameView

文字居中绘制

这里再说一个在绘制过程当中遇到一个小问题,能够看到在整个视图底部,绘制了一个半透明的圆角矩形,并在他上面绘制了一行黄色的文字,这行文字在水平和垂直方向都是居中的;使用TextPaint 绘制文字实现水平居中是很容易的事情,只须要设置mTipPaint.setTextAlign(Paint.Align.CENTER)便可,可是在垂直方向实现居中,就没那么简单了,这里须要考虑一个文本绘制时基线的问题,具体细节能够参考这篇文章,分析的很详细。

咱们在这里为了使文字在圆角矩形中居中,以下实现。

canvas.drawRoundRect(mRoundRect, 8, 8, mRectPaint);
        Paint.FontMetricsInt fontMetrics = mTipPaint.getFontMetricsInt();
        float baseY = (mRoundRect.bottom + mRoundRect.top) / 2 - (fontMetrics.top + fontMetrics.bottom) / 2;
        canvas.drawText(tips, screenW / 2, baseY, mTipPaint);复制代码

圆角矩形的垂直中心点的基础上,再一次作修正,确保实现真正的垂直居中。

好了,结合扔物线大神所总结的自定义View关键步骤,以上两点算是完成了绘制和布局的工做,下面就看看触摸反馈的实现。

触摸反馈-onTouchEvent

这里触摸反馈机制,使用到了GestureDetector这个类;这个类能够用来进行手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。内部提供了OnGestureListener、OnDoubleTapListener和OnContextClickListener三个接口,并提供了一系列的方法,好比常见的

  • onSingleTapUp : 手指轻触屏幕离开
  • onScroll : 滑动
  • onLongPress: 长按
  • onFling: 按下后,快速滑动松开(相似切水果的手势)
  • onDoubleTap : 双击

能够看到,使用这个类能够更加精确的处理手势操做。

这里引入GestureDetector的缘由是这样的,单独在onTouchEvent处理全部事件时,在手指点击屏幕的瞬间,很容易触发MotionEvent.ACTION_MOVE事件,致使每次触碰气泡,被点击气泡的位置都会稍微颤抖一下,位置发生轻微的偏移,体验十分糟糕。采用GestureDetector对手指滑动的处理,对点击和滑动的检测显得更加精确

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mValueAnimator != null) {
            if (mValueAnimator.isRunning()) {
                return false;
            }
        }
        m_gestureDetector.onTouchEvent(event);
        int lastX = (int) event.getX();
        int lastY = (int) event.getY();


        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            for (int i = 0; i < positions.length; i++) {
                int deltaX = positions[i].x - lastX;
                int deltaY = positions[i].y - lastY;

                // 手指 -- ACTION_DOWN 时,落在了某一个气泡上时,刷新选中气泡(球员)的bitmap
                if (Math.abs(deltaX) < playW / 2 && Math.abs(deltaY) < playW / 2) {
                    position = i;
                    currentPos = i;
                    invalidate();
                    moveEnable = true;
                    Log.e(TAG, "onTouchEvent: position= " + position);
                    return true;
                }


            }

            //没有点击中任意一个气泡,点击在外部是,重置气泡(球员)状态
            resetBubbleView();
            moveEnable = false;
            return false;
        }


        return super.onTouchEvent(event);

    }复制代码

这里m_gestureDetector.onTouchEvent(event),这样就可让GestureDetector在他本身的回调方法OnGestureListener里,处理触摸事件。

上面的逻辑很简单,动画正在进行是,直接返回。MotionEvent.ACTION_DOWN事件发生时的处理逻辑,经过注释很容易理解,就再也不赘述。

当咱们点击到某个气泡时,就获取到了当前选中位置currentPos;下面看看GestureDetector的回调方法,是怎样处理滑动事件的。

GestureDetector.OnGestureListener onGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (moveEnable) {
                positions[position].x -= distanceX;
                positions[position].y -= distanceY;


                //滑动时,考虑一下上下边界的问题,不要把球员移除场外
                // 横向就不考虑了,由于底图是3D 摆放的,上窄下宽,没法计算
                // 主要限制一下,纵向滑动值
                if (positions[position].y < minY) {
                    positions[position].y = minY;
                } else if (positions[position].y > maxY) {
                    positions[position].y = maxY;
                }

                Log.e(TAG, "onScroll: y=" + positions[position].y);

                //跟随手指,移动气泡(球员)
                invalidate();;
            }
            return true;
        }
    };复制代码

SimpleOnGestureListener 默认实现了OnGestureListener,OnDoubleTapListener, OnContextClickListener这三个接口中全部的方法,所以很是方便咱们使用GestureDetector进行特定手势的处理。

这里的处理很简单,当气泡被选中时moveEnable=true,经过onScroll回调方法返回的距离,不断更新当前位置的坐标,同时记得限制一下手势滑动的边界,总不能把球员移动到场地外面吧o(╯□╰)o,最后的postInvalidate()是关键,触发onDraw方法,实现从新绘制。

这里有一个细节,不知你发现没有,咱们在更新坐标的时候,每次都是在当前坐标的位置,减去了滑动距离(distanceX/distanceY)。这是为何(⊙o⊙)?,为何不是加呢?

咱们能够看看这个回调方法的定义

/** * Notified when a scroll occurs with the initial on down {@link MotionEvent} and the * current move {@link MotionEvent}. The distance in x and y is also supplied for * convenience. * * @param e1 The first down motion event that started the scrolling. * @param e2 The move motion event that triggered the current onScroll. * @param distanceX The distance along the X axis that has been scrolled since the last * call to onScroll. This is NOT the distance between {@code e1} * and {@code e2}. * @param distanceY The distance along the Y axis that has been scrolled since the last * call to onScroll. This is NOT the distance between {@code e1} * and {@code e2}. * @return true if the event is consumed, else false */
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);复制代码

能够看到,这里特定强调了This is NOT the distance between {@code e1}and {@code e2},就是说这个距离并非两次事件e1和e2 之间的距离。那么这个距离又是什么呢?那咱们就找一找究竟是在哪里触发了这个回调方法.

最终在GestureDetector类的onTouchEvent()方法里找到了触发这个方法发生的地方:

public boolean onTouchEvent(MotionEvent ev) {

    .....

        final boolean pointerUp =
                (action & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_UP;
        final int skipIndex = pointerUp ? ev.getActionIndex() : -1;

        // Determine focal point
        float sumX = 0, sumY = 0;
        final int count = ev.getPointerCount();
        for (int i = 0; i < count; i++) {
            if (skipIndex == i) continue;
            sumX += ev.getX(i);
            sumY += ev.getY(i);
        }
        final int div = pointerUp ? count - 1 : count;
        final float focusX = sumX / div;
        final float focusY = sumY / div;

        boolean handled = false;

        switch (action & MotionEvent.ACTION_MASK) {

        case MotionEvent.ACTION_MOVE:
            if (mInLongPress || mInContextClick) {
                break;
            }
            final float scrollX = mLastFocusX - focusX;
            final float scrollY = mLastFocusY - focusY;
            if (mIsDoubleTapping) {
                // Give the move events of the double-tap
                handled |= mDoubleTapListener.onDoubleTapEvent(ev);
            } else if (mAlwaysInTapRegion) {
                final int deltaX = (int) (focusX - mDownFocusX);
                final int deltaY = (int) (focusY - mDownFocusY);
                int distance = (deltaX * deltaX) + (deltaY * deltaY);
                if (distance > mTouchSlopSquare) {
                    handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
                    mLastFocusX = focusX;
                    mLastFocusY = focusY;
                    mAlwaysInTapRegion = false;
                    mHandler.removeMessages(TAP);
                    mHandler.removeMessages(SHOW_PRESS);
                    mHandler.removeMessages(LONG_PRESS);
                }
                if (distance > mDoubleTapTouchSlopSquare) {
                    mAlwaysInBiggerTapRegion = false;
                }
            } else if ((Math.abs(scrollX) >= 1) || (Math.abs(scrollY) >= 1)) {
                handled = mListener.onScroll(mCurrentDownEvent, ev, scrollX, scrollY);
                mLastFocusX = focusX;
                mLastFocusY = focusY;
            }
            break;


        return handled;
    }复制代码

这里还涉及到多指触控的考虑,状况较为复杂;简单说一下结论,在ACTION_MOVE时,会从上一次手指离开的距离,减去这次手指触碰的位置;这样当scrollX>0时,就是在向右滑动,反之向左;scrollY > 0 时,是在向上滑动,反之向下;所以,这两个距离和咱们习觉得常的方向刚好都是相反的,所以,在更新坐标时,须要作相反的处理。

有兴趣的同窗,能够把上面的“-”改为“+”,尝试运行一下代码,就会明白其中的道理了。

好了,到了这里按照绘制,布局,触摸反馈的顺序咱们已经完成了BallGameView这个自定义View本身的内容了,可是咱们还看到在点击下面的球员头像时,还有一个简单的动画,下面就看看动画是如何实现的。

动画效果

首先说明一下,底部球员列表是一个横向的RecyclerView,这样一个横向滑动的双列展现的RecyclerView 应该很简单了,这里就再也不详述。文末有源码,最后能够查看。

这里看一下每个RecyclerView中item的点击事件

@Override
    public void onRVItemClick(ViewGroup parent, View itemView, int position) {

        if (mPlayerBeanList.get(position).isSelected()) {
            Toast.makeText(mContext, "球员已被选择!", Toast.LENGTH_SHORT).show();
        } else {
            View avatar = itemView.findViewById(R.id.img);
            int width = avatar.getWidth();
            int height = avatar.getHeight();
            Bitmap bitmap = Tools.View2Bitmap(avatar, width, height);
            int[] location = new int[2];
            itemView.getLocationOnScreen(location);
            if (bitmap != null) {
                mGameView.updatePlayer(bitmap, mPlayerBeanList.get(position).getName(), location, content);
            }

        }

    }复制代码

这里能够看到调用了GameView的updatePlayer方法:

/** * 在下方球员区域,选中球员后,根据位置执行动画,将球员放置在选中的气泡中 * * @param bitmap 被选中球员bitmap * @param name 被选中球员名字 * @param location 被选中球员在屏幕中位置 * @param contentView 根视图(方便实现动画) */
    public void updatePlayer(final Bitmap bitmap, final String name, int[] location, final ViewGroup contentView) {

        Path mPath = new Path();
        mPath.moveTo(location[0] + bitmap.getWidth() / 2, location[1] - bitmap.getHeight() / 2);
        mPath.lineTo(positions[currentPos].x - playW / 2, positions[currentPos].y - playW / 2);


        final ImageView animImage = new ImageView(getContext());
        animImage.setImageBitmap(bitmap);
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(120, 120);
        contentView.addView(animImage, params);


        final float[] animPositions = new float[2];
        final PathMeasure mPathMeasure = new PathMeasure(mPath, false);

        mValueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                mPathMeasure.getPosTan(value, animPositions, null);

                animImage.setTranslationX(animPositions[0]);
                animImage.setTranslationY(animPositions[1]);

            }
        });

        mValueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);

                contentView.removeView(animImage);

                players[currentPos].setBitmap(bitmap);
                players[currentPos].setSetReal(true);
                players[currentPos].setName(name);

                invalidate();


            }
        });
        mValueAnimator.setDuration(500);
        mValueAnimator.setInterpolator(new AccelerateInterpolator());
        mValueAnimator.start();


    }复制代码

这个动画,简单来讲就是一个一阶贝塞尔曲线。根据RecyclerView中item在屏幕中的位置,构造一个如出一辙的ImageView添加到根视图中,而后经过一个属性动画,在属性值不断更新时,在回调方法中不断调用setTranslation方法,改变这个ImageView的位置,呈现出动画的效果。动画结束后,将这个ImageView从视图移除,同时气泡中的数据便可,最后再次invalidate致使整个视图从新绘制,这样动画完成时,气泡就被替换为真实的头像了。

到这里,基本上全部功能,都实现了。最后就是把本身排出来的阵型,保存为图片分享给小伙伴了。这里主要说一下保存图片的实现;分享功能,就不做为重点讨论了。

自定义View保存为Bitmap

private class SavePicTask extends AsyncTask<Bitmap, Void, String> {

        @Override
        protected String doInBackground(Bitmap... params) {
            Bitmap mBitmap = params[0];
            String filePath = "";
            Calendar now = new GregorianCalendar();
            SimpleDateFormat simpleDate = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault());
            String fileName = simpleDate.format(now.getTime());
            //保存在应用内目录,免去申请读取权限的麻烦
            File mFile = new File(mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES), fileName + ".jpg");
            try {
                OutputStream mOutputStream = new FileOutputStream(mFile);
                mBitmap.compress(Bitmap.CompressFormat.JPEG, 100, mOutputStream);
                mOutputStream.flush();
                mOutputStream.close();
                filePath = mFile.getAbsolutePath();


            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }


            return filePath;
        }
    }复制代码
mGameView.setDrawingCacheEnabled(true);
                Bitmap mBitmap = mGameView.getDrawingCache();

                if (mBitmap != null) {
                    new SavePicTask().execute(mBitmap);
                } else {
                    Toast.makeText(mContext, "fail", Toast.LENGTH_SHORT).show();
                }复制代码

一个典型的AsyncTask实现,文件流的输出,没什么多说的。主要是存储目录的选择,这里有个技巧,若是没有特殊限制,平时咱们作开发的时候,能够 把一些存储路径作以下定义

  • mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES):表明/storage/emulated/0/Android/data/{packagname}/files/Pictures
  • mContext.getExternalCacheDir() 表明 /storage/emulated/0/Android/data/{packagname}/cache

对于mContext.getExternalFilesDir还可定义为Environment.DIRECTORY_DOWNLOADS,Environment.DIRECTORY_DOCUMENTS等目录,对应的文件夹名称也会变化。

这个目录中的内容会随着用户卸载应用,一并删除。最重要的是,读写这个目录是不须要权限的,所以省去了每次作权限判断的麻烦,并且也避免了没有权限时的窘境

到这里,模仿功能,所有都实现了。下面稍微来一点额外的扩展。

咱们但愿图片保存后能够在通知栏提示用户,点击通知栏后能够经过手机相册查看保存的图片。

扩展-Android Notification & FileProvider 的使用

private void SaveAndNotify() {
        if (!TextUtils.isEmpty(picUrl)) {

            NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mContext);
            mBuilder.setWhen(System.currentTimeMillis())
                    .setTicker("下载图片成功")
                    .setContentTitle("点击查看")
                    .setSmallIcon(R.mipmap.app_start)
                    .setContentText("图片保存在:" + picUrl)
                    .setAutoCancel(true)
                    .setOngoing(false);
            //通知默认的声音 震动 呼吸灯
            mBuilder.setDefaults(NotificationCompat.DEFAULT_ALL);

            Intent mIntent = new Intent();
            mIntent.setAction(Intent.ACTION_VIEW);
            Uri contentUri;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                // 将文件转换成content://Uri的形式
                contentUri = FileProvider.getUriForFile(mContext, getPackageName() + ".provider", new File(picUrl));
                // 申请临时访问权限
                mIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            } else {
                contentUri = Uri.fromFile(new File(picUrl));
            }

            mIntent.setDataAndType(contentUri, "image/*");


            PendingIntent mPendingIntent = PendingIntent.getActivity(mContext
                    , 0, mIntent, PendingIntent.FLAG_UPDATE_CURRENT);
            mBuilder.setContentIntent(mPendingIntent);
            Notification mNotification = mBuilder.build();
            mNotification.flags |= Notification.FLAG_AUTO_CANCEL;
            NotificationManager mManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
            mManager.notify(0, mNotification);
        } else {
            T.showSToast(mContext, "图片保存失败");
        }
    }复制代码

Android 系统中的通知栏,随着版本的升级,已经造成了固定了写法,在Builder模式的基础上,经过链式写法,能够很是方便的设置各类属性。这里重点说一下PendingIntent的用法,咱们知道这个PendingIntent 顾名思义,就是处于Pending状态,当咱们点击通知栏,就会触发他所包含的Intent。

严格来讲,经过本身的应用想用手机自带相册打开一张图片是没法实现的,由于没法保证每一种手机上面相册的包名是同样的,所以这里咱们建立ACTION=Intent.ACTION_VIEW的 Intent,去匹配系统全部符合这个Action 的Activity,系统相册必定是其中之一。

到这里,还有必定须要注意,Android 7.0 开始,没法以file://xxxx 形式向外部应用提供内容了,所以须要考虑使用FileProvider。固然,对这个问题,Google官方提供了完整的使用实例,实现起来都是套路,没有什么特别之处。

重点记住下面的对应关系便可:

<root-path/> 表明设备的根目录new File("/");
 <files-path/> 表明context.getFilesDir()
 <cache-path/> 表明context.getCacheDir()
 <external-path/> 表明Environment.getExternalStorageDirectory()
 <external-files-path>表明context.getExternalFilesDirs()
 <external-cache-path>表明getExternalCacheDirs()复制代码

按照上面,咱们存储图片的目录,咱们在file_path.xml 作以下定义便可:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="root" path=""/>
</paths>复制代码

在AndroidManifest中完成以下配置 :

<!-- Android 7.0 FileUriExposedException -->
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_path"/>
        </provider>复制代码

这样,当Build.VERSION.SDK_INT大于等于24及Android7.0时,能够安心的使用FileProvider来和外部应用共享文件了。

最后

好了,从一个简单的自定义View 出发,又牵出了一大堆周边的内容。好在,总算完整的说完了。

特别申明

以上代码中所用到的图片资源,所有源自懂球帝APP内;此处对应用解包,只是本着学习的目的,没有其余任何用意。


源码地址: Github-AndroidAnimationExercise

有兴趣的同窗欢迎 star & fork。

相关文章
相关标签/搜索