Things take time

[Android] 뷰페이저를 이용한 앨범에서 여러 사진 불러오기 본문

Android(기능)

[Android] 뷰페이저를 이용한 앨범에서 여러 사진 불러오기

겸손할 겸 2017. 7. 20. 14:14

[결과 미리보기]



기본 액티비티 : 앨범을 호출하는 버튼하나만을 갖고있다.



버튼을 눌러 안드로이드 내장 앨범을 선택한 경우, 디바이스에 따라 바로 앨범이 나오거나 갤러리, 포토 등의 여러 앱들을 선택하는 창이 나올 수 있다.

여기서는 3개의 사진을 선택했다.



뷰페이저 액티비티에서는 첫 번째 사진이 먼저 보이게 된다. 그리고 각 이미지들은 원본 이미지가 아니라, 화질이 저하된 (1/4)이미지들이다.



뷰페이저는 기본적으로 터치 좌우 드래그를 통해 다음 사진으로 넘길 수 있다.

이전, 다음 버튼은 혹시 그 기능을 모르는 사용자가 있을 수 있다는 가정하에 만든 버튼이다.



마지막 사진까지 이동한 모습



사진을 점검하고, 원본과 저화질 중 선택하여 사진들을 보낼 수 있다.

각 개별사진에 대한 원본, 저화질을 지정하는 것이 아니라 가져온 사진들을 공통적으로 사용하는 옵션이다.

저화질로 선택 후 전송하면 저화질로된 이미지들은 새로 앨범에 저장된다.



그리고 모든 사진이 업로드되면 완료 토스트 메시지와 함께 뷰페이저 액티비티 finish()



서버(PHP)에 파일들을 전송하고, 서버에 올라간 파일 목록

업로드는 따로 포스팅하고, 앨범에서 선택한 이미지들을 들고와 뷰 페이저에 뿌리고 선택한 화질에 따른 추가 작업을 하는 것까지가 이번 예제다.

 

 


[소스 코드]


1) 퍼미션

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION">
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION">
    <uses-permission android:name="android.permission.INTERNET">
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE">
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE">
    <uses-permission android:name="android.permission.CAMERA">
    <!-- 카메라 사용, 5.0(API 21)이상부터는 camera2 권장, 기본 내장 카메라 사용 시 권한 요청 필요 없음 -->
    <uses-feature android:name="android.hardware.camera">

2) 메인 액티비티의 변수 및 onCreate, getAlbum

    Button albumBtn;
    final int REQUEST_TAKE_ALBUM = 1;
    PermissionCheck permissionCheck;

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

        permissionCheck = new PermissionCheck(MainActivity.this);
        albumBtn = (Button) findViewById(R.id.btn_album);

        albumBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                getAlbum();
            }
        });
    }


    private void getAlbum(){
        // 앨범 호출
        boolean isAlbum = permissionCheck.isCheck("Album");
        if(isAlbum){
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                try {
                    Intent intent = new Intent(Intent.ACTION_PICK);
                    intent.setType("image/*");
                    intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
                    intent.setType(android.provider.MediaStore.Images.Media.CONTENT_TYPE);
                    startActivityForResult(Intent.createChooser(intent,"다중 선택은 '포토'를 선택하세요."), REQUEST_TAKE_ALBUM);
                }catch(Exception e){
                    Log.e("error", e.toString());
                }
            }
            else{
                Log.e("kitkat under", "..");
            }
        }
    }


퍼미션체크를 먼저하고, 여부에 따라 getAlbum()을 호출한다. 권한이 허용된 상태라면 바로 호출된다. 퍼미션은 따로 설명하지 않는다. 마시멜로 버전 이상부터는 필수 구현이다.


getAlbum()의 putExtra부분을 주목한다. EXTRA_ALLOW_MULTIPLE을 넣어줘야 다중 선택이 가능하다. 안드로이드 내장 기능들에게 보내는 인텐트 메시지.


3) 메인 액티비티 ActivityResult

  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Log.i("onActivityResult", "CALL");
        super.onActivityResult(requestCode, resultCode, data);
        switch (requestCode) {
            case REQUEST_TAKE_ALBUM:
                Log.i("result", String.valueOf(resultCode));
                if (resultCode == Activity.RESULT_OK) {
                    ArrayList imageList = new ArrayList<>();

                    // 멀티 선택을 지원하지 않는 기기에서는 getClipdata()가 없음 => getData()로 접근해야 함
                    if (data.getClipData() == null) {
                        Log.i("1. single choice", String.valueOf(data.getData()));
                        imageList.add(String.valueOf(data.getData()));
                    } else {

                        ClipData clipData = data.getClipData();
                        Log.i("clipdata", String.valueOf(clipData.getItemCount()));
                        if (clipData.getItemCount() > 10){
                            Toast.makeText(MainActivity.this, "사진은 10개까지 선택가능 합니다.", Toast.LENGTH_SHORT).show();
                            return;
                        }
                        // 멀티 선택에서 하나만 선택했을 경우
                        else if (clipData.getItemCount() == 1) {
                            String dataStr = String.valueOf(clipData.getItemAt(0).getUri());
                            Log.i("2. clipdata choice", String.valueOf(clipData.getItemAt(0).getUri()));
                            Log.i("2. single choice", clipData.getItemAt(0).getUri().getPath());
                            imageList.add(dataStr);

                        } else if (clipData.getItemCount() > 1 && clipData.getItemCount() < 10) {
                            for (int i = 0; i < clipData.getItemCount(); i++) {
                                Log.i("3. single choice", String.valueOf(clipData.getItemAt(i).getUri()));
                                imageList.add(String.valueOf(clipData.getItemAt(i).getUri()));
                            }
                        }
                    }

                    Intent intent = new Intent(MainActivity.this, ImagesView.class);
                    intent.putStringArrayListExtra("list", imageList);
                    startActivity(intent);
                } else {
                    Toast.makeText(MainActivity.this, "사진 선택을 취소하였습니다.", Toast.LENGTH_SHORT).show();
                }
                break;
        }
    }

중요한 부분, 설명은 주석으로 마무리지으며, 추가로 getClipData()는 EXTRA_ALLOW_MULTIPLE가 제대로 먹힌 디바이스에서 가져오는 결과 값이며, null을 리턴한다면 여러 장 선택을 지원하지 않는 기기를 의미한다. 이 기기까지를 지원하기 원한다면 -> 그냥 외부 라이브러리를 사용하자.

참고 : https://developer.android.com/reference/android/content/Intent.html#EXTRA_ALLOW_MULTIPLE



4 뷰페이저 액티비티 기본 변수

    ViewPager pager;
    PagerAdapter pagerAdapter;

    Spinner spinner;
    boolean isHigh = true;
    boolean isChoice = false;
    ProgressDialog progressDialog;
    // 저화질용(디코드된 비트맵 리스트)
    List<:bitmap> lowList = new ArrayList<>();
    // 원본용(해당 파일의 경로 리스트)
    ArrayList<:string> highList = new ArrayList<>();
    // 넘어온 용
    ArrayList<:string> priorList = new ArrayList<>();
    String flag = "";
    Button submitBtn;
    Button priorBtn, nextBtn;

세 개의 리스트를 기억하자. 비트맵 전용 lowList, String값(파일 path)을 저장하는 highList, 인텐트를 통해 넘어온 이미지 리스트 경로를 저장할 priorList가 있다. 


priorList는 이전에 사진을 선택한 URI값을 갖고 있는데..

콘텐츠 URI(content:// ...)혹은 그냥 파일 경로(file://)로 리턴받는 애들이 있다. (디바이스 차이) 후자의 경우, 바로 해당 경로로 접근해서 업로드해버리면 되지만.. 콘텐츠 URI는 URI를 파일 경로로 변환하는 함수를  거쳐야한다.


** 로직

priorList : 앨범에서 가져온 사진들의 원본 경로를 저장하고 있다. 원본 경로는 URI값 혹은 file path 둘 중 하나의 값이며 URI의 경우 file path로 변환해야 한다.

highList : priorList에 갖고 있는 경로를 편집하여, 모두 파일 경로로 변환하여 저장한다. 원본을 선택하고 전송버튼을 눌렀을 때 이 리스트에 경로 String값들을 업로드 클래스에 보내고, 해당 경로를 참조하여 업로드한다.

lowList : priorList, highList처럼 해당 파일들의 경로를 갖고있는 것이 아니라, 비트맵을 갖고 있는 리스트다. 페이저뷰를 보여줄 때, 이미지를 디코딩 할때 디코딩한 비트맵을 갖고있는 리스트다. 나중에 저화질로 전송을 할때는 이 리스트에 있는 비트맵들을 꺼내서, 파일로 변환 & 저장 & 동기화하고 해당 파일의 경로를 리턴받아 lowPaths라는 String 리스트에 저장한다. lowPaths가 저화질용 이미지의 파일 경로들을 저장한 리스트며, 이 리스트를 업로드용 클래스에 보낸다. 이 리스트는 위처럼 여러번 편집 후에 사용한다.



5) 뷰페이저 액티비티 onCreate

  @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (Build.VERSION.SDK_INT >= 21) {
            getSupportActionBar().hide();
        } else if (Build.VERSION.SDK_INT < 21) {
            requestWindowFeature(Window.FEATURE_NO_TITLE);
        }
        setContentView(R.layout.activity_images_view);
        pager = (ViewPager) findViewById(R.id.pager);
        spinner = (Spinner) findViewById(R.id.spinner);
        submitBtn = (Button) findViewById(R.id.btn_submit);
        priorBtn = (Button) findViewById(R.id.btn_prior);
        nextBtn = (Button) findViewById(R.id.btn_next);

        spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
            @Override
            public void onItemSelected(AdapterView parent, View view, int position, long id) {
                String choiceItem = (String) parent.getSelectedItem();
                if (choiceItem.equals("원본")){
                    isHigh = true;
                    isChoice = true;
                } else if (choiceItem.equals("저화질")){
                    isHigh = false;
                    isChoice = true;
                } else {
                    isChoice = false;
                }
            }

            @Override
            public void onNothingSelected(AdapterView parent) {
            }
        });

        progressDialog = new ProgressDialog(ImagesView.this);
        progressDialog.setTitle("이미지를 불러오고 있습니다");
        progressDialog.setMessage("잠시만 기다려 주세요");
        progressDialog.show();

        priorList = getIntent().getStringArrayListExtra("list");
        flag = priorList.get(0).substring(0,7);


        // highList에는 일단 원본을 넣어둠
        for (int i = 0; i < priorList.size(); i++) {
            if (flag.equals("content")) {
                // content:// -> /storage... (포토)
                String path = getRealPathFromURI(Uri.parse(priorList.get(i)));
                highList.add(path);
            } else {
                // 갤러리 : 파일 절대 경로 리턴함(변환은 필요없고, file://를 빼줘야 업로드시 new File에서 이용)
                String path = priorList.get(i).replace("file://", "");
                highList.add(path);
            }
        }


        pager.setOffscreenPageLimit(priorList.size()-1);
        pagerAdapter = new PagerAdapter() {
            @Override
            public int getCount() {
                return priorList.size();
            }

            // 뷰페이저가 열릴때 초기화하는 함수, Default로는 현재 이미지와 다음 이미지(1개)를 기본 저장함
            @Override
            public Object instantiateItem(ViewGroup container, final int position) {

                View childView = getLayoutInflater().inflate(R.layout.childview, null);
                final ImageView childImgView= (ImageView)childView.findViewById(R.id.iv_childImgView);

                Uri uri = Uri.parse(highList.get(position));
                final String path = uri.getPath();

                // Async
                LoadBitmap loadBitmap = new LoadBitmap(childImgView);
                loadBitmap.execute(path);

                // 컨테이너에 일단 이미지뷰를 갖고 있는 childView레이아웃을 넣어두고.. 늦더라도, 작업 후 onPostExecute에서 setImageBitmap을 해줄 것
                container.addView(childView);
                return childView;
            }

            @Override
            public void destroyItem(ViewGroup container, int position, Object object) {
                // super.destroyItem(container, position, object);
                container.removeView((View)object);
            }

            @Override
            public void finishUpdate(ViewGroup container) {
                super.finishUpdate(container);
                if(count == priorList.size()){
                    progressDialog.dismiss();
                } else {
                    count ++;
                }

            }

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

        // 뷰 페이저에 아답터 연결
        pager.setAdapter(pagerAdapter);


        submitBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                uploadImages();
            }
        });

        priorBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.i("pager", String.valueOf(pager.getCurrentItem()));
                if(pager.getCurrentItem() > 0) {
                    pager.setCurrentItem(pager.getCurrentItem() - 1);
                }
            }
        });
        nextBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if(pager.getCurrentItem() < pager.getOffscreenPageLimit() + 1){
                    pager.setCurrentItem(pager.getCurrentItem() + 1);
                }
            }
        });
    }

가장 중요한 부분, 뷰페이저의 setOffscreenPageLimit옵션을 사용하여 뷰페이저가 열릴 때 몇개의 페이저를 열것인지 설정하는데.. 여기선 최대 사이즈 -1만큼을 설정하여, 뷰페이저가 열릴 때 미리 다 열어둔다. 이유는 메모리 저하 경고 및 로딩 렉때문이다. 차라리 처음 열때 미리 다 로드해놓고, 프로그레스 다이얼로그를 띄우는 것이 좋다. -1의 이유는 처음 열리는 페이지는 제외되기 때문이다.


비트맵을 전환하는 곳에서 메인 쓰레드 에러가 발생하기 때문에 해당 하는 곳에서 Async를 사용하여 별도 쓰레드 구성 및 UI 분리 작업을 수행했다.


뷰페이저의 현재 위치를 아는 getCurrentItem()과.. 페이지 이동을 설정하는 setCurrentItem()을 기억하자. 



6) 뷰페이저 액티비티 : Async

/**
     * String : doInBackground에서 넘겨 받을 파라미터, 파일의 경로를 의미함
     * Bitmap : UI업데이트를 위한 것, doInBackground에서 UI용 객체를 만들고 onPostExecute로 전달
     */
    class LoadBitmap extends AsyncTask{
        ImageView targetView;
        LoadBitmap(ImageView imageView) {
            targetView = imageView;
        }

        @Override
        protected Bitmap doInBackground(String... params) {
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inSampleSize = 2;
            Bitmap bitmap = BitmapFactory.decodeFile(params[0], options);
            lowList.add(bitmap);
            return bitmap;
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            targetView.setImageBitmap(bitmap);
        }
    }

Async 사용법좀 익숙해져야지



7) 뷰페이저 액티비티 : 업로드할 함수

   private void uploadImages(){
        UploadImage uploadImage = new UploadImage(ImagesView.this);
        if(isHigh && isChoice){
            Log.i("UploadImages", "원본 업로드 시작");
            uploadImage.setInit(highList, "GYEOM");
            String url = "https://app2.okdongchang.kr/UploadImage.php";
            try {
                String returnString = uploadImage.execute(url).get();
                if(returnString != null) {
                    Log.i("returnString", returnString);
                }
            } catch (Exception e){
                Log.e("error", e.toString());
            }
            // Async에서 결과값 얻기
            highList.clear();
        } else if (!isHigh && isChoice){
            Log.i("UploadImages", "저화질 업로드 시작");
            List<String> lowPaths = new ArrayList<>();

            for(int i=0; i<lowList.size(); i++){
                String bitmapPath = SaveBitmapToFileCache(lowList.get(i));
                lowPaths.add(bitmapPath);
            }
            uploadImage.setInit(lowPaths, "GYEOM");
            String url = "https://app2.okdongchang.kr/UploadImage.php";
            try {
                String returnString = uploadImage.execute(url).get();
                if(returnString != null) {
                    finish();
                }
            } catch (Exception e){
                Log.e("error", e.toString());
            }
            lowList.clear();
        } else {
            Toast.makeText(ImagesView.this, "전송할 화질을 선택하세요", Toast.LENGTH_SHORT).show();
        }
        isHigh = true;
    }

lowPaths는 저화질용 이미지의 경로를 의미하고, highList는 해당 원본에 대한 이미지 경로를 의미한다. 저화질은 저화질용 비트맵 -> 파일 생성 -> 파일 저장 및 경로 리턴의 과정을 거쳐야한다.

 

 

*** 중요 (2017/07/25)

 

String returnString = uploadImage.execute(url).get();

이 부분은 Async로 도는 클래스가 끝났을 때 리턴받기 위한.. 일종의 중간 끼어들기나 다름없다. Async에서 return을하게 되면 받을 수 있는 값이라.. 이 값이 넘어오면 Async가 완료된 것으로 판단하고 이 다음 작업을 처리하려 하기 위해 이처럼 사용했다.

 

그러나 이걸 사용하면 프로그레스 다이얼로그 같은 애들을 사용할 수 없다.

이유는 중간에 껴드는 저 작업 때문에 다이얼로그를 preExecute에서 만들어도, 금방 사라져버리게 되는 것이다.

 

개인적으로는 위의 get보다는 postExecute에서 작업할 수 있도록 UI컴포넌트를 던져주던지, callback함수를 구현하는 것이 좋다.


8) 뷰페이저 액티비티 : 비트맵 -> 파일로 변경하는 함수(Content URI로 이미지가 넘어온 경우, 이 방법을 사용 : 최근 기기에선 다 Content URI로 값이 리턴되는 편이고 옛날 버전이나 기기에선 filePath로 바로 넘어옴)

  // Bitmap to File
    public String SaveBitmapToFileCache(Bitmap bitmap) {
        Random random = new Random();
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        String imageFileName = timeStamp + "_" + random.nextInt(10000) + ".jpg";
        File fileCacheItem = new File(Environment.getExternalStorageDirectory().getAbsolutePath()+ "/new/"+ imageFileName);

        File forlder = new File(Environment.getExternalStorageDirectory().getAbsolutePath()+ "/new/");
        // 경로 만들기
        if (!forlder.exists()) {
            forlder.mkdirs();
        }

        OutputStream out = null;
        try {
            fileCacheItem.createNewFile();
            out = new FileOutputStream(fileCacheItem);
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        // 해당 이미지 저장 동기화
        Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
        // 해당 경로에 있는 파일을 객체화(새로 파일을 만든다는 것으로 이해하면 안 됨)
        Uri contentUri = Uri.fromFile(fileCacheItem);
        mediaScanIntent.setData(contentUri);
        sendBroadcast(mediaScanIntent);

        return fileCacheItem.getAbsolutePath();
    }

저화질용 리스트는 기존 이미지의 1/4로 줄여진 비트맵을 저장한 리스트이기때문에.. 이 리스트에 저장된 비트맵을 파일로 변환하고, 해당 파일의 경로를 리턴한다.



9) 뷰페이저 액티비티 : Content URI -> File Path

  // ContentURI -> File Path
    public String getRealPathFromURI(Uri contentUri) {
        Cursor cursor = null;
        try {
            String[] proj = { MediaStore.Images.Media.DATA };
            cursor = getContentResolver().query(contentUri,  proj, null, null, null);
            int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
            cursor.moveToFirst();
            return cursor.getString(column_index);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

자주 사용할 수 있는 함수니 기억할 것.. 넘어온 이미지 경로가 file path가 아니라 uri값으로 넘어온 경우 file path로 변경해주는 함수다.


나머지는 풀소스 참고