카이스트 몰입캠프 1주차 프로젝트
- Gradle: 7.0.4
- Android SDK Platform 32
dependencies {
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.gms:play-services-location:19.0.0'
implementation 'com.naver.maps:map-sdk:3.13.0'
implementation 'com.google.android.material:material:1.1.0'
implementation 'com.naver.maps:map-sdk:3.13.0'
def room_version = "2.4.0"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
// optional - RxJava2 support for Room
implementation "androidx.room:room-rxjava2:$room_version"
// optional - RxJava3 support for Room
implementation "androidx.room:room-rxjava3:$room_version"
// optional - Guava support for Room, including Optional and ListenableFuture
implementation "androidx.room:room-guava:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
// optional - Paging 3 Integration
implementation "androidx.room:room-paging:$room_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
testImplementation 'junit:junit:4.13.2'
}
- The application consists of three functionality
- Each function is served by TabActivity as fragment
연락처 정보는 ContentsPrvider에서 제공하는 ContactsContract 데이터베이스를 이용했습니다.
ContactsContract DB의 CommonDataKinds.Phone테이블에서 어플리케이션 상의 연락처에 보여줄 정보들을 ContactsData 객체의 필드에 할당하고, 이를 ContactsAdapter상에서 ArrayList로 관리 및 사용했습니다.
//ContactData.java public class ContactData { private long portraitSrc, contact_id; private String name, phoneNum, description; public ContactData(){}; public ContactData(long portraitSrc, String name, String phoneNum, String description, long contact_id) { this.portraitSrc = portraitSrc; this.name = name; this.phoneNum = phoneNum; this.description = description; this.contact_id = contact_id; } //..Getter & Setter}//ContactAdapter.java public ArrayList<ContactData> getContactData(String input) { Uri uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI; //android provider 에서 제공하는 데이터 식별자 //ContactsContract.Contacts - Constants for the Contact table //ContactsContract.CommonDataKinds = ContactsContract.Data 테이블의 common data type 을 정의 String[] qr = new String[]{"...queries"}; //..NullException String sortOrder = ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME; ArrayList<ContactData> result; //Set Where Clause String where = ContactsContract.CommonDataKinds.Phone.NUMBER + " LIKE '%" + input + "%'" + " OR " + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " LIKE '%" + input + "%'"; if (input.matches("[+-]?\\d*(\\.\\d+)?")) { //'-'없는 숫자 쿼리 핸들링 where += handleAdditionalQuery(input); } try (Cursor cursor = context.getContentResolver().query(uri, qr, where, null, sortOrder)) { //SELECT qr FROM ContactsContract.CommonDataKinds.Phone DESC/ASC ~~ result = new ArrayList<ContactData>(); if (cursor.moveToFirst()) { do { //...Set datas to ContactData object } while (cursor.moveToNext()); } } catch (Exception e) { e.printStackTrace(); result = null; } return result; }내부 연락처에서 정보를 가져와 Adapter상의 ContactData list에 불러오는 과정은 JVM에 별도로 Thread를 할당한 뒤 결과값을 Handler로 받아오도록 구현했습니다.
//ContactAdapter.java new Thread(){ @Override public void run() { super.run(); contactDatas = getContactData(retrieve); Message msg = hd.obtainMessage(1, contactDatas); hd.sendMessage(msg); } }.start();Contact Fragment — Listener
- 상단의 검색창 텍스트 변경 시 retrieve함수를 호출하여 실시간으로 검색 및 출력합니다.
//Contact.java public void afterTextChanged(Editable editable) { recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_contacts); ContactAdapter contactAdapter = (ContactAdapter) recyclerView.getAdapter(); assert contactAdapter != null; contactAdapter.retrieveContact(editable.toString()); }- 하단의 추가 버튼 클릭 시 연락처 추가 Activity로 이동합니다.
//Contact.java public void onClick(View view) { Intent intent = new Intent(ContactsContract.Intents.Insert.ACTION); intent.setType(ContactsContract.RawContacts.CONTENT_TYPE); requireContext().startActivity(intent); }Recyclerview — Listener
- ItemView를 길게 클릭 시 연락처 수정 및 삭제에 대한 Dialog 호출합니다. 선택에 따라 해당하는 동작 수행합니다.
//ContactAdapter.java @Override public boolean onLongClick(View view) { AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setItems(putOrDeleteMenu, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int menuPosition) { if (menuPosition == 0) { context.startActivity(new Intent(Intent.ACTION_EDIT, Uri.parse(ContactsContract.Contacts.CONTENT_URI + "/" + Long.toString(indivContact.getContact_id())))); notifyItemChanged(position); } else if (menuPosition == 1) { contactDatas.remove(position); //db뿐만 아니라 recyclerview 내의 데이터도 삭제해줘야 함 notifyItemRemoved(position); } } }); builder.setNegativeButton("취소", null); builder.show(); return true; }- Swipe event의 경우 SwipeController 클래스를 추가적으로 구현한 뒤 Recyclerview에 붙여서 처리했습니다. SwipeController 는 ItemtouchHelper.Callback 클래스를 상속한 클래스이며 swipe관련 method를 오버라이딩하여 사용하는 Class입니다.
//SwipeController.java @Override public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { if (actionState == ACTION_STATE_SWIPE) { drawButtons(c, viewHolder, (int)dX); setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); if (dX >= buttonWidth|| dX <= -buttonWidth){ return; } } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); }Swipe event를 통한 변위에 따라 메시지, 통화에 대한 안내문이 출력됩니다. 손가락을 뗐을 때에는 진동과 함께 통화 혹은 메시지에 대한 activity를 호출합니다.//SwipeController.java @Overridepublic boolean onTouch(View v, MotionEvent event) { swipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP; if (swipeBack && !currentTaskon) { currentTaskon = true; if (buttonWidth < dX){ call(viewHolder); activateVibrator(); recyclerView.getChildAt(viewHolder.getAdapterPosition()).setX(buttonWidth); } else if (-buttonWidth > dX){ message(viewHolder); activateVibrator(); recyclerView.getChildAt(viewHolder.getAdapterPosition()).setX(-buttonWidth); } currentTaskon = false; } return false;}
Gallery의 경우 Fragment - RecyclerView - Item(Fragment - RecuclerView - Item)의 이중 RecyclerView로 구성하였다.
RecyclerView의 Item으로 Fragment를 바인딩 한 후 다시 내부에 RecyclerView를 넣은 이유는, LiveData를 사용함에 있어 앨범 별로 이미지를 따로 다루기 위해 앨범마다 ImageViewModel을 할당해야 했기 때문이다.
DB를 사용한 이유는 어플리케이션을 실행할 때 마다 매번 MediaStore에서 Load하게 되면 사용자 경험에 좋지 않은 영향을 끼칠 것이라 판단하였기 때문이다. 어플이 처음 실행될 때, 그리고 Refresh버튼을 클릭할 때 MediaStore를 이용해 Storage와 DB를 연동하고, Image의 Uri는 LiveData를 이용해 DB에서 쿼리해 오도록 설계하였다. 이미지 로드시 뷰를 실시간으로 업데이트 하여, 사용자로 하여금 로딩이 크게 체감되지 않도록 하였다.
다만 Item에 Fragment를 바인딩 할 때, 화면에 “Draw”되지 않은 View에 Fragment add/replace가 되지 않는 현상이 있어 onDrawListener를 활용하여 ViewHolder가 화면에 Draw되는 시점에 Fragment를 바인딩한다. 아래는 해당 코드부인 AlbumAdapter의 onBindViewHolder 함수이다.
@OverridepublicvoidonBindViewHolder(@NonNullAlbumViewHolderholder,intposition){finalAlbumUrialbumUri=albumUris.get(position);//고유 id를 발급받아 뷰에 적용시켜야 서로 다른 fragment를 붙일 수 있음.holder.itemView.setId(View.generateViewId());intcontainerId=holder.itemView.getId();FragmentoldFragment=fm.findFragmentById(containerId);if(oldFragment!=null){fm.beginTransaction().remove(oldFragment).commit();}holder.drawed=0;//Fragment는 draw 상태인 View에만 붙일 수 있다. //따라서 DrawListener에서 바인딩을 해주면 된다.//그냥 하면 뷰 없다고 에러 발생AlbumFragmentalbumFragment=AlbumFragment.newInstance(albumUri.getAlbumPath(),albumUri.getAlbumName(),context);holder.itemView.getViewTreeObserver().addOnDrawListener(newViewTreeObserver.OnDrawListener(){
@OverridepublicvoidonDraw(){if(holder.drawed==0){fm.beginTransaction().replace(containerId,albumFragment).commit();holder.drawed=1;}}});}MapFragment는 OnMapReadyCallback인터페이스를 구현하는 Fragment입니다. 네이버 지도 안드로이드 SDK로부터 지도를 불러오는 작업이 완료되면 onMapReady함수에서 FusedLocationSource와 LocationTrackingMode등 지도에 필요한 설정을 마친 후 지도를 표시합니다.
//MapFragment.java@UiThread@OverridepublicvoidonMapReady(@NonNullNaverMapnaverMap) { naverMap.setLocationSource(fusedLocationSource); naverMap.setLocationTrackingMode(LocationTrackingMode.Face); naverMap.getUiSettings().setLocationButtonEnabled(true); naverMap.addOnLocationChangeListener(newNaverMap.OnLocationChangeListener() { @OverridepublicvoidonLocationChange(@NonNullLocationlocation) { latitude = location.getLatitude(); longitude = location.getLongitude(); } }); currentNaverMap = naverMap; mapSearchAdapter.setCurrentMap(currentNaverMap); }
지도를 포함한 Map Fragment를 기반으로 세 번째 탭의 View를 구성하였습니다. EditText를 포함한 UI에서 검색을 실행할 시 기본 검색/근처 검색 설정 여부에 따라 네이버 지역 검색 API에 각각 다른 Query Parameter로 요청을 보냈습니다.
//MapFragment.javaStringquery=""; Addressaddress = list.get(0); if (isCurrentLocationMode > 0){ query = address.getAddressLine(0) + " " + additionalQuery; } else{ query = additionalQuery; } String[] params = {query}; newSearchTask().execute(params);
검색 버튼이 입력을 받았을 경우 HTTP Connection에 대한 작업을 수행하는 것이기 때문에 Fragment에서 SearchTask를 AsyncTask로 실행합니다. HttpConnection에 대한 헤더 설정 및 QueryString을 추가해주는 과정을 거치게 됩니다.
//MapFragment.javaprivateclassSearchTaskextendsAsyncTask<String, Void, ArrayList<SearchResult>> { StringbaseURL = "https://openapi.naver.com/v1/search/local.json"; URLsearchURL; Stringquery; HttpURLConnectionconnection; @OverrideprotectedArrayList<SearchResult> doInBackground(String... params) { query = params[0]; ArrayList<SearchResult> searchResults = newArrayList<SearchResult>(); try { Stringutf8query = URLEncoder.encode(query, "utf-8"); StringrequestQuery = addQueryString(baseURL, utf8query, "10", "1", "random"); searchURL = newURL(requestQuery); connection = (HttpURLConnection) searchURL.openConnection(); if (connection != null){ connection.setConnectTimeout(10000); connection.setRequestMethod("GET"); connection.setDoInput(true); //no doOutPutconnection.setRequestProperty("HOST", "openapi.naver.com"); connection.setRequestProperty("Content-Type", "plain/text"); //...//Request Headersintcode = connection.getResponseCode(); if (code == HttpURLConnection.HTTP_OK){ InputStreaminputStream = connection.getInputStream(); BufferedReaderbufferedReader = newBufferedReader(newInputStreamReader(inputStream)); StringBuilderstringBuilder = newStringBuilder(); Stringline; while ((line = bufferedReader.readLine()) != null){ stringBuilder.append(line+"\n"); } bufferedReader.close(); Stringresult = stringBuilder.toString(); JSONObjectresponseObject = newJSONObject(result); JSONArrayjsonArray = (JSONArray) responseObject.get("items"); for (inti = 0; i < jsonArray.length(); i++){ //..Add Json Results to JsonObject } } } } catch (IOException | JSONExceptione) { e.printStackTrace(); } returnsearchResults; }
검색에 대한 결과는 RecyclerView로 제공됩니다. 이 때 해당 결과 뷰를 길게 터치하거나 검색 결과를 따라서 맵에 표기된 마커를 터치하면 해당 위치로 줌 인과 함게 이동합니다. 이 때 네이버 지역 검색 API가 제공하는 카텍 좌표계와 moveCamera메서드가 기준으로 삼는 위도, 경도 좌표계가 다르기 때문에 이에 대한 변환 절차가 필요합니다.
//MapFragment.javapublicLatLngtranslateCoordinate(intcoord_x, intcoord_y){ GeoTransPointoKA = newGeoTransPoint(coord_x, coord_y); GeoTransPointoGeo = GeoTrans.convert(GeoTrans.KATEC, GeoTrans.GEO, oKA); doublelat = oGeo.getY(); doublelng = oGeo.getX(); returnnewLatLng(lat, lng); }
해당 메서드에 이용된 GeoTransPoint와 GeoTrans클래스는 오픈소스를 참고하였습니다.
Recyclerview상에 표기된 버튼을 터치 시 지역 검색 API를 이용했을 때와 같은 방식으로 Fragment내부에 다시 Fragment View 및 Recyclerview가 표기됩니다. 해당 view는 다른 영역을 터치 시 사라집니다.
//MapSearchAdapter.javaif (motionEvent.getAction() == MotionEvent.ACTION_UP) { myImage.setColorFilter(Color.parseColor("#00ABCAB2"), PorterDuff.Mode.SRC_OVER); ReviewDisplayFragmente = newReviewDisplayFragment(individSearchResult.getTitle()); e.show(((FragmentActivity)currentContext).getSupportFragmentManager(), "event"); }
생성된 Fragment내에는 블로그 리뷰들에 대한 미리보기들이 view로 나열되어 있으며, 이를 터치 시 해당 블로그 링크에 대한 인터넷 브라우저 접속으로 이어집니다.
//ReviewAdapter.java@OverridepublicvoidonClick(Viewview) { Intentintent = newIntent(Intent.ACTION_VIEW, Uri.parse(individReviewData.getLink())); currentContext.startActivity(intent); }
MapFragment는 OnMapReadyCallback인터페이스를 구현하는 Fragment입니다. 네이버 지도 안드로이드 SDK로부터 지도를 불러오는 작업이 완료되면 onMapReady함수에서 FusedLocationSource와 LocationTrackingMode등 지도에 필요한 설정을 마친 후 지도를 표시합니다.
//MapFragment.java@UiThread@OverridepublicvoidonMapReady(@NonNullNaverMapnaverMap) { naverMap.setLocationSource(fusedLocationSource); naverMap.setLocationTrackingMode(LocationTrackingMode.Face); naverMap.getUiSettings().setLocationButtonEnabled(true); naverMap.addOnLocationChangeListener(newNaverMap.OnLocationChangeListener() { @OverridepublicvoidonLocationChange(@NonNullLocationlocation) { latitude = location.getLatitude(); longitude = location.getLongitude(); } }); currentNaverMap = naverMap; mapSearchAdapter.setCurrentMap(currentNaverMap); }
지도를 포함한 Map Fragment를 기반으로 세 번째 탭의 View를 구성하였습니다. EditText를 포함한 UI에서 검색을 실행할 시 기본 검색/근처 검색 설정 여부에 따라 네이버 지역 검색 API에 각각 다른 Query Parameter로 요청을 보냈습니다.
//MapFragment.javaStringquery=""; Addressaddress = list.get(0); if (isCurrentLocationMode > 0){ query = address.getAddressLine(0) + " " + additionalQuery; } else{ query = additionalQuery; } String[] params = {query}; newSearchTask().execute(params);
검색 버튼이 입력을 받았을 경우 HTTP Connection에 대한 작업을 수행하는 것이기 때문에 Fragment에서 SearchTask를 AsyncTask로 실행합니다. HttpConnection에 대한 헤더 설정 및 QueryString을 추가해주는 과정을 거치게 됩니다.
//MapFragment.javaprivateclassSearchTaskextendsAsyncTask<String, Void, ArrayList<SearchResult>> { StringbaseURL = "https://openapi.naver.com/v1/search/local.json"; URLsearchURL; Stringquery; HttpURLConnectionconnection; @OverrideprotectedArrayList<SearchResult> doInBackground(String... params) { query = params[0]; ArrayList<SearchResult> searchResults = newArrayList<SearchResult>(); try { Stringutf8query = URLEncoder.encode(query, "utf-8"); StringrequestQuery = addQueryString(baseURL, utf8query, "10", "1", "random"); searchURL = newURL(requestQuery); connection = (HttpURLConnection) searchURL.openConnection(); if (connection != null){ connection.setConnectTimeout(10000); connection.setRequestMethod("GET"); connection.setDoInput(true); //no doOutPutconnection.setRequestProperty("HOST", "openapi.naver.com"); connection.setRequestProperty("Content-Type", "plain/text"); //...//Request Headersintcode = connection.getResponseCode(); if (code == HttpURLConnection.HTTP_OK){ InputStreaminputStream = connection.getInputStream(); BufferedReaderbufferedReader = newBufferedReader(newInputStreamReader(inputStream)); StringBuilderstringBuilder = newStringBuilder(); Stringline; while ((line = bufferedReader.readLine()) != null){ stringBuilder.append(line+"\n"); } bufferedReader.close(); Stringresult = stringBuilder.toString(); JSONObjectresponseObject = newJSONObject(result); JSONArrayjsonArray = (JSONArray) responseObject.get("items"); for (inti = 0; i < jsonArray.length(); i++){ //..Add Json Results to JsonObject } } } } catch (IOException | JSONExceptione) { e.printStackTrace(); } returnsearchResults; }
검색에 대한 결과는 RecyclerView로 제공됩니다. 이 때 해당 결과 뷰를 길게 터치하거나 검색 결과를 따라서 맵에 표기된 마커를 터치하면 해당 위치로 줌 인과 함게 이동합니다. 이 때 네이버 지역 검색 API가 제공하는 카텍 좌표계와 moveCamera메서드가 기준으로 삼는 위도, 경도 좌표계가 다르기 때문에 이에 대한 변환 절차가 필요합니다.
//MapFragment.javapublicLatLngtranslateCoordinate(intcoord_x, intcoord_y){ GeoTransPointoKA = newGeoTransPoint(coord_x, coord_y); GeoTransPointoGeo = GeoTrans.convert(GeoTrans.KATEC, GeoTrans.GEO, oKA); doublelat = oGeo.getY(); doublelng = oGeo.getX(); returnnewLatLng(lat, lng); }
해당 메서드에 이용된 GeoTransPoint와 GeoTrans클래스는 오픈소스를 참고하였습니다.
Recyclerview상에 표기된 버튼을 터치 시 지역 검색 API를 이용했을 때와 같은 방식으로 Fragment내부에 다시 Fragment View 및 Recyclerview가 표기됩니다. 해당 view는 다른 영역을 터치 시 사라집니다.
//MapSearchAdapter.javaif (motionEvent.getAction() == MotionEvent.ACTION_UP) { myImage.setColorFilter(Color.parseColor("#00ABCAB2"), PorterDuff.Mode.SRC_OVER); ReviewDisplayFragmente = newReviewDisplayFragment(individSearchResult.getTitle()); e.show(((FragmentActivity)currentContext).getSupportFragmentManager(), "event"); }
생성된 Fragment내에는 블로그 리뷰들에 대한 미리보기들이 view로 나열되어 있으며, 이를 터치 시 해당 블로그 링크에 대한 인터넷 브라우저 접속으로 이어집니다.
//ReviewAdapter.java@OverridepublicvoidonClick(Viewview) { Intentintent = newIntent(Intent.ACTION_VIEW, Uri.parse(individReviewData.getLink())); currentContext.startActivity(intent); }
===













