728x90

적용화면

 

준비 

1. 스플래시 이미지 찾기

https://lottiefiles.com/

 

LottieFiles: Download Free lightweight animations for website & apps.

Effortlessly bring the smallest, free, ready-to-use motion graphics for the web, app, social, and designs. Create, edit, test, collaborate, and ship Lottie animations in no time!

lottiefiles.com

사용할 애니매이션

2. 사용할 애니메이션 다운로드 (Json 파일) 

 

3. lottie 파일 res/raw 안에 대문자 없이 넣어주기

 

4. AndroidManifest.xml 앱 실행 루트 변경 -> ex) SplashActivity 

 

5. 라이브러리 추가 

        // lottie
	implementation ("com.airbnb.android:lottie:6.3.0")

	// android splash
	implementation("androidx.core:core-splashscreen:1.0.1")

 

 

 

 

SplashActivity

더보기
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.nbc.curtaincall.R
import com.nbc.curtaincall.ui.main.MainActivity

class SplashActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_splash)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        lifecycleScope.launch {
            delay(3000)
            startActivity(Intent(this@SplashActivity, MainActivity::class.java))
            finish()
        }
    }
}

activity_splash.xml 

더보기
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/black"
    tools:context=".presentation.main.SplashActivity">

    <com.airbnb.lottie.LottieAnimationView
        android:id="@+id/lottie"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:lottie_autoPlay="true"
        app:lottie_loop="true"
        app:layout_constraintBottom_toTopOf="@id/tv_title"
        app:lottie_rawRes="@raw/lottie_splash" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/black"
        android:text="ShowNect"
        android:textColor="@color/primary_color"
        android:textSize="40sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

라이브러리추가

 

 

-----수정-----

 

themes.xml 

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.CurtainCall" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor">@color/background_color</item>
        <!-- Customize your theme here. -->
        <item name="android:windowBackground">@color/black</item>
        <item name="android:windowIsTranslucent">true</item> //추가 해줘야 스플래시 이중으로 안뜸
    </style>
    
</resources>

 

728x90

TicketDialogFragment / OnViewCreated

//Swipe Gesture
        binding.layoutSimpleScrollview.setOnTouchListener(object :
            OnSwipeTouchListener(requireContext()) {
            override fun onSwipeTop() {
                super.onSwipeTop()
                val intent = Intent(context, DetailActivity::class.java).apply {
                    putExtra(Constants.SHOW_ID, ticketId)
                    putExtra(Constants.FACILITY_ID, facilityId)
                }
                startActivity(intent)
                activity?.overridePendingTransition(R.anim.slide_up, R.anim.no_animation)
            }
        })
    }

 

onSwipeTop() 오버라이드해서 onSwipeTop() 호출될때의 코드 작성 

예시에는 DetailActivity를 띄워주는 코드 

 

activity?.overridePendingTransition(R.anim.slide_up, R.anim.no_animation) 

- 액티비티 띄울 때 애니매이션 적용 (아래에서 위로)

 

res/anim/slide_up.xml / res/anim/no_animtaion.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="500"
        android:fromYDelta="100%"
        android:toYDelta="0%" />
</set>

//no_animation.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

</set>

 

 

OnSwipeTouchListener 

더보기
import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
//Gesture
open class OnSwipeTouchListener(ctx: Context) : View.OnTouchListener {

    private val gestureDetector: GestureDetector

    companion object {
        private val SWIPE_THRESHOLD = 500 // 스와이프 거리를 늘릴 값
        private val SWIPE_VELOCITY_THRESHOLD = 500 // 스와이프 인식에 필요한 최소 속도를 늘릴 값
    }

    init {
        gestureDetector = GestureDetector(ctx, GestureListener())
    }

    private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {

        override fun onDown(e: MotionEvent): Boolean {
            return true
        }

        override fun onFling(
            e1: MotionEvent?,
            e2: MotionEvent,
            velocityX: Float,
            velocityY: Float
        ): Boolean {
            var result = false
            try {
                val diffY = e2.y - e1!!.y
                val diffX = e2.x - e1.x
                if (Math.abs(diffX) > Math.abs(diffY)) {
                    if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
                        if (diffX > 0) {
                            onSwipeRight()
                        } else {
                            onSwipeLeft()
                        }
                        result = true
                    }
                } else if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) {
                    if (diffY > 0) {
                        onSwipeBottom()
                    } else {
                        onSwipeTop()
                    }
                    result = true
                }
            } catch (exception: Exception) {
                exception.printStackTrace()
            }

            return result
        }

    }

    fun onSwipeRight() {}

    fun onSwipeLeft() {}

    open fun onSwipeTop() {}

    fun onSwipeBottom() {}
    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        try {
            return gestureDetector.onTouchEvent(event!!)
        } catch (e: Exception) {
            // Error Handling
        }
        return false
    }
}

 

SimpleOnGestureListener

  • 제스처 이벤트를 감지하는 데 사용되는 여러 콜백 메서드를 기본 구현으로 제공

onDown(e: MotionEvent): Boolean

  • 메서드는 모든 제스처 이벤트가 시작될 때 호출 (true를 반환하여 이벤트 시퀀스를 처리)

onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean

  • onFling(스크롤과 비슷하지만 손가락으로 튕길 때)
  • 플링 제스처가 발생했을 때 호출 

 

참고 

https://evolog.tistory.com/7

 

[코틀린] Kotlin Swipe 동작 구현하기 (OnSwipeTouchListener)

개요 Kotlin 코드로 View에 Swipe 동작 구현 코드 정리 입니다. 매번 검색하기 번거로워 정리하려 합니다. 핵심은 OnSwipeTouchListener Class 구현 코드를 정리 입니다. (※ 참고 : https://stackoverflow.com/questions/

evolog.tistory.com

 

728x90

HomeFragment 상단 뷰 페이저

 

HomeFragment 

더보기
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator
import com.nbc.shownect.R
import com.nbc.shownect.databinding.FragmentHomeBinding
import com.nbc.shownect.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.shownect.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.shownect.presentation.home.adapter.GenreAdapter
import com.nbc.shownect.presentation.home.adapter.KidShowAdapter
import com.nbc.shownect.presentation.home.adapter.PosterClickListener
import com.nbc.shownect.presentation.home.adapter.TopRankAdapter
import com.nbc.shownect.presentation.home.adapter.UpcomingShowAdapter
import com.nbc.shownect.presentation.main.MainViewModel
import com.nbc.shownect.presentation.main.MainViewModelFactory
import com.nbc.shownect.presentation.ticket.TicketDialogFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class HomeFragment : Fragment(), PosterClickListener {
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!
    private val viewModel: HomeViewModel by viewModels {
        HomeViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch),
        )
    }
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(
                fetch
            )
        )
    }
    private val upComingShowAdapter: UpcomingShowAdapter by lazy { UpcomingShowAdapter(this) }
    private val topRankAdapter: TopRankAdapter by lazy { TopRankAdapter(this) }
    private val genreAdapter: GenreAdapter by lazy { GenreAdapter(this) }
    private val kidShowAdapter: KidShowAdapter by lazy { KidShowAdapter(this) }
    private var isPaging = false
    private var pagingJob: Job? = null
    private val onPageChangeCallback: OnPageChangeCallback = object : OnPageChangeCallback() {
        //페이지 선택 될 때마다 호출
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
            binding.tvPageIndicator.text = "${position + 1} / 10"
        }
    }
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        initViews()
        setUpObserve()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        with(viewModel) {
            //공연 예정작
            fetchUpcoming()
            //TOP 10 공연
            fetchTopRank()
            //장르 스피너 선택
            binding.spinnerHomeGenre.setOnSpinnerItemSelectedListener<String> { _, _, newIndex, _ ->
                fetchGenre(newIndex)
            }
            //어린이 관람 가능 공연 목록
            fetchKidShow()
        }
    }

    //화면 초기 설정
    private fun initViews() {
        //어뎁터 초기화
        upComingShowAdapter
        topRankAdapter
        kidShowAdapter
        initRecyclerView()

        //viewPager 연결
        with(binding.viewPager) {
            adapter = upComingShowAdapter
            //viewPager PageTransformer 세팅
            offscreenPageLimit = 1
            setPageTransformer(SliderTransformer(requireContext()))
            val itemDecoration = HorizontalMarginItemDecoration(
                requireContext(),
                R.dimen.viewpager_current_item_horizontal_margin
            )
            addItemDecoration(itemDecoration)


            registerOnPageChangeCallback(onPageChangeCallback)

            //tab 연결
            TabLayoutMediator(binding.tabPosterIndicator, this) { tab, position ->
                currentItem = tab.position
            }.attach()
        }
        //장르 연극 초기화
        viewModel.fetchGenre(0)
    }

    //옵저브 세팅
    private fun setUpObserve() {
        with(viewModel) {
            showList.observe(viewLifecycleOwner) {
                upComingShowAdapter.submitList(it)
                if (!isPaging) startPaging()
            }
            topRank.observe(viewLifecycleOwner) {
                topRankAdapter.submitList(it?.take(10))
            }
            genre.observe(viewLifecycleOwner) {
                genreAdapter.submitList(it)
            }
            kidShow.observe(viewLifecycleOwner) {
                kidShowAdapter.submitList(it)
            }
            //로딩 화면 처리
            isLoadingGenre.observe(viewLifecycleOwner) {
                binding.skeletonGenreLoading.isVisible = !it
            }
            isLoadingRecommend.observe(viewLifecycleOwner) {
                binding.skeletonTopRankLoading.isVisible = !it
            }
            isLoadingKid.observe(viewLifecycleOwner) {
                binding.skeletonKidLoading.isVisible = !it
            }

        }
    }

    //리사이클러뷰 초기화
    private fun initRecyclerView() {
        with(binding) {
            //HOT 추천 리사이클러뷰
            rvHomeTopRank.apply {
                adapter = topRankAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //장르별 리사이클러뷰
            rvHomeGenre.apply {
                adapter = genreAdapter
                layoutManager =
                    LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //어린이 공연 리사이클러뷰
            rvHomeKidShow.apply {
                adapter = kidShowAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
        }
    }

    //3초 후 자동 페이징
    private fun nextPage() {
        runCatching {
            with(binding) {
                if (viewPager.currentItem == 9) {
                    lifecycleScope.launch {
                        delay(3000)
                    }
                    viewPager.currentItem = 0
                } else {
                    viewPager.currentItem++
                }
            }
        }.onFailure {
            Toast.makeText(context, "Exception nextPage()", Toast.LENGTH_SHORT).show()
        }
    }

    //페이징 스타트 함수
    private fun startPaging() {
        isPaging = true
        pagingJob = lifecycleScope.launch {
            while (true) {
                delay(3000)
                nextPage()
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        isPaging = false
        pagingJob?.cancel()
    }

    //포스터 클릭 시 티켓
    override fun posterClicked(id: String) {
        val ticketDialog = TicketDialogFragment()
        sharedViewModel.sharedShowId(id) //해당 공연의 id를 MainViewModel로 보내줌
        ticketDialog.setStyle(
            DialogFragment.STYLE_NORMAL,
            R.style.RoundCornerBottomSheetDialogTheme
        )
        ticketDialog.show(childFragmentManager, ticketDialog.tag)
    }
}

offscreenPageLimit = 1 

  • 미리 로딩할 페이지 수

SliderTransformer - 페이지 변환 효과, 스크롤될 때 각 페이지에 적용되는 시각적 변환

  • transformPage 메소드는 페이지의 위치(position)에 따라 페이지의 변환을 결정
  • position 값은 현재 중앙에 위치한 페이지를 기준으로 왼쪽에 있는 페이지는 음수(-1, -0.5 등), 오른쪽에 있는 페이지는 양수(0.5, 1 등)으로 표현
  • page.translationX는 페이지의 가로 위치를 조정하여, 스크롤 시 페이지가 어떻게 이동할지 정의 (페이지 사이의 간격을 조정하는 데 사용)
  • page.scaleY는 페이지의 세로 축 스케일을 조정하여, 페이지가 어느 정도 떨어져 있을 때 크기가 줄어들게 함
  • page.alpha는 투명도 설정 (페이지가 중앙에 있을 때(position == 0)) , 최대 100% 불투명도(1.0f) 중앙에서 멀어질 수록 투명 (0.25f 는 최소 투명도)

리소스에서 다음 페이지가 보이는 부분(nextItemVisiblePx)과 현재 페이지의 수평 마진(currentItemHorizontalMarginPx)을 가져오고, 이 두 값을 합산하여 pageTranslationX를 계산 (스크롤 시 페이지의 위치 변환에 사용)

더보기
import android.content.Context
import android.view.View
import androidx.viewpager2.widget.ViewPager2
import com.nbc.shownect.R
import kotlin.math.abs

class SliderTransformer(context: Context) :
    ViewPager2.PageTransformer {
    override fun transformPage(page: View, position: Float) {
        page.translationX = -pageTranslationX * position
        page.scaleY = 1 - (0.25f * abs(position))
        page.alpha = 0.25f + (1 - abs(position))
    }

    val nextItemVisiblePx =
        context.resources.getDimension(R.dimen.viewpager_next_item_visible)
    val currentItemHorizontalMarginPx =
        context.resources.getDimension(R.dimen.viewpager_current_item_horizontal_margin)
    val pageTranslationX = nextItemVisiblePx + currentItemHorizontalMarginPx
}

 

HorizontalMarginItemDecoration - 리사이클러뷰 항목에 수평 마진을 추가하는 기능 (주로 리사이클러 뷰 내부의 아이템 간격을 조정하는 데 사용)

  • 생성자는 Context와 마진 값을 DP 단위로 받아 해당 값을 픽셀 단위로 변환
  • context.resources.getDimension() 리소스 ID를 통해 해당 마진 값의 실제 픽셀 크기를 가져옴
  • getItemOffsets() 메소드는 리사이클러뷰 내 각 항목에 대한 오프셋(여기서는 마진)을 설정, outRect.left와 outRect.right를 설정하여 각 항목의 왼쪽과 오른쪽에 마진을 적용
더보기
import android.content.Context
import android.graphics.Rect
import android.view.View
import androidx.annotation.DimenRes
import androidx.recyclerview.widget.RecyclerView

class HorizontalMarginItemDecoration(context: Context, @DimenRes horizontalMarginInDp: Int) :
    RecyclerView.ItemDecoration() {
    private val horizontalMarginInPx: Int =
        context.resources.getDimension(horizontalMarginInDp).toInt()

    override fun getItemOffsets(
        outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State
    ) {
        outRect.right = horizontalMarginInPx
        outRect.left = horizontalMarginInPx
    }
}

res/values/dimens/dimens.xml

더보기
<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
    //추가된 부분 
    <dimen name="viewpager_next_item_visible">26dp</dimen>
    <dimen name="viewpager_current_item_horizontal_margin">42dp</dimen>
</resources>

viewpager_next_item_visible

  • ViewPager2를 사용할 때, 슬라이드되고 있는 다음 항목이 얼마나 보여질지를 정의하는 값
  • SliderTransformer Class에서 사용 

viewpager_current_item_horizontal_margin

  • 현재 활성화된 ViewPager2 항목의 수평 마진 크기를 정의
  • 현재 보여지는 항목과 양 옆의 항목 사이의 간격을 결정
  • HorizontalMarginItemDecoration Class 에서 사용 

 

참고 

https://github.com/unaisulhadi/ViewPager2-Carousel

 

GitHub - unaisulhadi/ViewPager2-Carousel: A simple Carousel for ViewPager2

A simple Carousel for ViewPager2. Contribute to unaisulhadi/ViewPager2-Carousel development by creating an account on GitHub.

github.com

https://stackoverflow.com/questions/71108306/viewpager2-left-and-right-preview-item-scrolling-not-working

 

Viewpager2 left and right preview item scrolling not working

I am trying to create an image slider using ViewPager2 with left and right previews. Everything is working perfectly, but scrolling on left and right preview items is not working. There is a similar

stackoverflow.com

 

728x90

블러처리된 이미지 백그라운드

 

프로젝트에서 이미지를 블러 처리할 일 이 생겨서 방법을 찾아 보았다. 

 

처음에는 Glide에서 BlurTransformation 을 지원해주는 줄 알았지만 wasabeef 의 라이브러리를 추가해 주어야 했다.

여기서 BlurTransformation()이 사용이안되어서 오랜시간 삽질을 했다. 

 

 

이미지를 처리하기 위한 라이브러리 추가 

 

사용방법  

			Glide.with(requireContext())
                            .load(showDetail.poster)
                            .apply(RequestOptions.bitmapTransform(BlurTransformation(15, 1)))
                            .into(ivSimplePosterBlur)

 

이미지를 블러 처리하는 부분  

.apply(RequestOptions.bitmapTransform(BlurTransformation(15, 1)))

BlurTransformation 은 radius (15) , sampling (1) 를 매개변수로 받는다.

 

Radius

  • 수치가 높을 수록 더 강한 블러처리 (이 값이 클수록 이미지가 더 흐릿해짐)
  • radius가 클수록 더 많은 픽셀이 블러 처리
  • 보통 1부터 25까지 처리 가능   

sampling 

  • 이미지를 샘플링하는 데 사용
  • 블러 효과의 품질을 결정
  • 높은 샘플링 값은 더 고해상도의 이미지를 사용하여 블러를 생성하므로 더 세밀한 블러를 얻을 수 있지만, 그만큼 처리 시간이 더 오래 걸릴 수 있음 (sampling 값을 높이면 성능은 향상되지만, 이미지의 디테일이 손실될 수 있음)
  • 최소값은 1 (1을 설정하면 이미지를 축소하지 않고 원본 해상도 그대로 블러 처리)

 

 

 

 

 

 

라이브러리 깃 허브 

 

BlurTransformation 

https://github.com/wasabeef/glide-transformations

 

GitHub - wasabeef/glide-transformations: An Android transformation library providing a variety of image transformations for Glid

An Android transformation library providing a variety of image transformations for Glide. - wasabeef/glide-transformations

github.com

 

Glide 

https://github.com/bumptech/glide

 

GitHub - bumptech/glide: An image loading and caching library for Android focused on smooth scrolling

An image loading and caching library for Android focused on smooth scrolling - bumptech/glide

github.com

 

728x90

화면이 스크롤 되어도 따라다닐 수 있게 AppBarlayout 으로 감싸준 다음 ViewPager와 탭을 연결

activity_detail.xml 

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background_color"
    tools:context=".ui.detail_activity.DetailActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/detail_appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tab_layout_detail"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/background_color"
            app:tabIndicatorColor="@color/primary_color"
            app:tabRippleColor="@null"
            app:tabTextColor="@color/white">

        </com.google.android.material.tabs.TabLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/detail_ViewPager"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/detail_appbar" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

 

DetailActivity 

package com.nbc.shownect.ui.detail_activity

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.tabs.TabLayoutMediator
import com.nbc.shownect.databinding.ActivityDetailBinding
import com.nbc.shownect.ui.detail_activity.adapter.DetailPagerAdapter
import com.nbc.shownect.util.Constants

class DetailActivity : AppCompatActivity() {
    private lateinit var binding: ActivityDetailBinding
    private val detailViewModel by lazy { ViewModelProvider(this)[DetailViewModel::class.java] }
    private val adapter: DetailPagerAdapter by lazy { DetailPagerAdapter(this) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)
        //뷰 페이저 탭 연결 하는 부분 // 
        with(binding) {
            detailViewPager.adapter = adapter
            TabLayoutMediator(tabLayoutDetail, detailViewPager) { tab, position ->
                detailViewPager.currentItem = tab.position
                tab.text = when (position) {
                    0 -> "상세 정보"
                    1 -> "소개 이미지"
                    2 -> "공연장 위치"
                    3 -> "기대평/리뷰"
                    else -> ""
                }
            }.attach()
        }
        ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        //TicketFragment에서 공연 id,공연장 id 받아 오는 코드 
        val showId = intent.getStringExtra(Constants.SHOW_ID)
        val facilityId = intent.getStringExtra(Constants.FACILITY_ID)
        if (showId != null && facilityId != null) {
            detailViewModel.sharedId(showId, facilityId)
        }
    }
}

 

728x90

프래그먼트간 데이터를 공유하려면 여러 가지 방법이있다.

  • ViewModel 사용하기 
    • 여러 프래그먼트가 같은 액티비티에 속해 있으면, 이 프래그먼트들은 같은 ViewModel을 공유해서 데이터를 주고받을 수 있어서 프래그먼트들 사이의 결합도가 낮아지고, 앱을 더 쉽게 관리할 수 있다.
  • 액티비티를 통해 데이터 공유하기
    • 프래그먼트는 액티비티에 붙어 있어서 액티비티를 매개체로 삼아 데이터를 주고받을 수 있다.
    • 한 프래그먼트에서 액티비티의 메소드를 호출해 데이터를 전달하고, 다른 프래그먼트에서는 액티비티를 통해 그 데이터를 가져올 수 있다.
  • 인터페이스 사용하기
    • 데이터를 공유할 때 인터페이스를 정의해서 사용하는 방법.
    • 액티비티에서 인터페이스를 구현하고, 데이터를 보내고 싶은 프래그먼트에서는 인터페이스 메소드를 호출해 데이터를 전달하면 다른 프래그먼트에서 이 인터페이스를 통해 데이터를 받을 수 있다.
    • 복잡하다.
  • Bundle 사용해서 데이터 전달하기
    • 프래그먼트를 전환할 때 Bundle에 데이터를 담아서 보낼 수 있음.
    • 주로 새 프래그먼트를 만들 때 시작 데이터를 전달하는 데 사용됨.
  • EventBus 같은 외부 라이브러리 사용하기
    • EventBus 같은 라이브러리를 사용해서 앱 전체에서 이벤트를 발행하고 구독함으로써 프래그먼트 간에 데이터를 공유할 수 있다.
    • 코드를 더 간결하게 만들어 주고, 결합도를 낮출 수 있지만, 외부 라이브러리에 의존하게 됨.
  • Shared Preferences로 데이터 공유하기
    • 간단한 데이터는 Shared Preferences를 통해 저장하고 공유할 수 있음. 앱의 설정 값이나 작은 데이터 조각을 저장하는 데 주로 사용
    • 매번 sharedPreference에서 값을 읽어와야하는 번거로움.

 

이번 프로젝트에서는 ViewModel을 사용하는 방법을 적용해 보았다.  

 

Activity에 포함된 둘 이상의 프래그먼트는 ViewModel을 통해서 데이터 공유가 가능하다.

 

Fragment들은 각자의 ViewModel (by viewModels()) 를 가질 수도 있고,

Activity의 ViewModel (by activityViewModels)  을 공유해서 사용할 수도 있는 것

 

프로젝트에 적용해 보기

해당 공연의 id를 받아와서 데이터를 뿌려주는 화면

HomeFragment ( TicketDialogFragment 으로 데이터를 보낼 Fragment

더보기
package com.nbc.curtaincall.ui.home

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayoutMediator
import com.nbc.curtaincall.R
import com.nbc.curtaincall.databinding.FragmentHomeBinding
import com.nbc.curtaincall.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.curtaincall.ui.home.adapter.GenreAdapter
import com.nbc.curtaincall.ui.home.adapter.KidShowAdapter
import com.nbc.curtaincall.ui.home.adapter.PosterClickListener
import com.nbc.curtaincall.ui.home.adapter.TopRankAdapter
import com.nbc.curtaincall.ui.home.adapter.UpcomingShowAdapter
import com.nbc.curtaincall.ui.main.MainViewModel
import com.nbc.curtaincall.ui.main.MainViewModelFactory
import com.nbc.curtaincall.ui.ticket.TicketDialogFragment
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class HomeFragment : Fragment(), PosterClickListener {
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!
    //해당 프레그먼트의 ViewModel
    private val viewModel: HomeViewModel by viewModels {
        HomeViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch),
        )
    }
    //해당 액티비티의 ViewModel -> sharedViewModel을 통해서 데이터 공유
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(
                fetch
            )
        )
    }
    private val upComingShowAdapter: UpcomingShowAdapter by lazy { UpcomingShowAdapter() }
    private val topRankAdapter: TopRankAdapter by lazy { TopRankAdapter(this) }
    private val genreAdapter: GenreAdapter by lazy { GenreAdapter(this) }
    private val kidShowAdapter: KidShowAdapter by lazy { KidShowAdapter(this) }
    private var isPaging = false
    private var pagingJob: Job? = null

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        initViews()
        setUpObserve()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        with(viewModel) {
            //공연 예정작
            fetchUpcoming()
            //TOP 10 공연
            fetchTopRank()
            //장르 스피너 선택
            binding.spinnerHomeGenre.setOnSpinnerItemSelectedListener<String> { _, _, newIndex, _ ->
                fetchGenre(newIndex)
            }
            //어린이 관람 가능 공연 목록
            fetchKidShow()
        }
    }

    //화면 초기 설정
    private fun initViews() {
        //어뎁터 초기화
        upComingShowAdapter
        topRankAdapter
        kidShowAdapter
        initRecyclerView()
        with(viewModel) {
            showList.observe(viewLifecycleOwner) {
                upComingShowAdapter.submitList(it)
                with(binding) {
                    //viewpager 연결
                    viewPager.adapter = upComingShowAdapter
                    //tab 연결
                    TabLayoutMediator(tabPosterIndicator, viewPager) { tab, position ->
                        viewPager.currentItem = tab.position
                    }.attach()
                }
                if (!isPaging) startPaging()
            }
            //장르 연극 초기화
            fetchGenre(0)
        }
    }

    //옵저브 세팅
    private fun setUpObserve() {
        with(viewModel) {
            topRank.observe(viewLifecycleOwner) {
                topRankAdapter.submitList(it.take(10))
            }
            genre.observe(viewLifecycleOwner) {
                genreAdapter.submitList(it)
            }
            kidShow.observe(viewLifecycleOwner) {
                kidShowAdapter.submitList(it)
            }
        }
    }

    //리사이클러뷰 초기화
    private fun initRecyclerView() {
        with(binding) {
            //HOT 추천 리사이클러뷰
            rvHomeTopRank.apply {
                adapter = topRankAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //장르별 리사이클러뷰
            rvHomeGenre.apply {
                adapter = genreAdapter
                layoutManager =
                    LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
            //어린이 공연 리사이클러뷰
            rvHomeKidShow.apply {
                adapter = kidShowAdapter
                layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            }
        }
    }

    //3초 후 자동 페이징
    private fun nextPage() {
        runCatching {
            with(binding) {
                if (viewPager.currentItem == 9) {
                    lifecycleScope.launch {
                        delay(3000)
                    }
                    viewPager.currentItem = 0
                } else {
                    viewPager.currentItem++
                }
            }
        }.onFailure {
            Toast.makeText(context, "Exception nextPage()", Toast.LENGTH_SHORT).show()
        }
    }

    //페이징 스타트 함수
    private fun startPaging() {
        isPaging = true
        pagingJob = lifecycleScope.launch {
            while (true) {
                delay(3000)
                nextPage()
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        isPaging = false
        pagingJob?.cancel()
    }

    //포스터 클릭 시 티켓
    override fun posterClicked(id: String) {
        val ticketDialog = TicketDialogFragment()
        sharedViewModel.sharedShowId(id) //해당 공연의 id를 MainViewModel로 보내줌
        ticketDialog.setStyle(
            DialogFragment.STYLE_NORMAL,
            R.style.RoundCornerBottomSheetDialogTheme
        )
        ticketDialog.show(childFragmentManager, TicketDialogFragment().tag)
    }
}

 

TicketDialogFragment (HomeFragment 에서 데이터를 받아올 Fragment) 

더보기
package com.nbc.curtaincall.ui.ticket

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import coil.load
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.nbc.curtaincall.databinding.SimpleInfoBottomsheetDialogBinding
import com.nbc.curtaincall.fetch.network.retrofit.RetrofitClient.fetch
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import com.nbc.curtaincall.ui.main.MainViewModel
import com.nbc.curtaincall.ui.main.MainViewModelFactory

class TicketDialogFragment : BottomSheetDialogFragment() {
    private var _binding: SimpleInfoBottomsheetDialogBinding? = null
    private val binding get() = _binding!!
    //해당 액티비티의 ViewModel -> sharedViewModel을 통해서 데이터 공유
    private val sharedViewModel: MainViewModel by activityViewModels<MainViewModel> {
        MainViewModelFactory(
            fetchRemoteRepository = FetchRepositoryImpl(fetch = fetch)
        )
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = SimpleInfoBottomsheetDialogBinding.inflate(inflater, container, false)

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        //TODO Swipe To Action
        with(sharedViewModel) {
            //HomeFragment에서 id를 보내주면 fetchShowDetail() 호출
            showId.observe(viewLifecycleOwner) { id ->
                sharedViewModel.fetchShowDetail(id)
            }
            showDetailInfo.observe(viewLifecycleOwner) { showDetailInfoList ->
                with(binding) {
                    ivDetailPosterImage.load(showDetailInfoList[0].poster)
                    tvDetailAge.text = showDetailInfoList[0].prfage
                    tvDetailRuntime.text = showDetailInfoList[0].prfruntime
                    tvDetailFacilityName.text = showDetailInfoList[0].dtguidance
                    tvDetailPrice.text= showDetailInfoList[0].pcseguidance
                    tvDetailFacilityName.text = showDetailInfoList[0].fcltynm
                    tvDetailGenre.text = showDetailInfoList[0].genrenm
                }
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }
}

HomeViewModel (액티비티의 ViewModel로 MainViewModel 에서 데이터 공유)

더보기
package com.nbc.curtaincall.ui.main

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.nbc.curtaincall.fetch.model.DbResponse
import com.nbc.curtaincall.fetch.repository.impl.FetchRepositoryImpl
import kotlinx.coroutines.launch

class MainViewModel(private val fetchRemoteRepository: FetchRepositoryImpl) : ViewModel() {
    //공연 상세 정보 리스트
    private val _showDetailInfo = MutableLiveData<List<DbResponse>>()
    val showDetailInfo: LiveData<List<DbResponse>> get() = _showDetailInfo
    //공연 id
    private val _showId = MutableLiveData<String>()
    val showId: LiveData<String> get() = _showId

    //id를 공유 하기 위한 함수
    fun sharedShowId(id: String) {
        _showId.value = id
    }

    //공연 상세 정보를 받아옴
    fun fetchShowDetail(id: String) {
        viewModelScope.launch {
            _showDetailInfo.value = fetchRemoteRepository.fetchShowDetail(path = id).showList
        }
    }
}

class MainViewModelFactory(private val fetchRemoteRepository: FetchRepositoryImpl):ViewModelProvider.Factory{
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return MainViewModel(fetchRemoteRepository) as T
    }
}

 

 

HomeFragment 에서 MainViewModel 로 id를 보내주고 TicketDialogFragment 에서 저장된 id 를 꺼내쓰는 구조 

Fragment가 여러개라도 속해있는 Activity에서 파생되었기 때문에 Activity를 통해서 데이터 공유가 가능하다. 

728x90

 

@GET("pblprfr")
    suspend fun fetchShowList(
        @Query("stdate") stdate: String = Constants.START_DATE,
        @Query("eddate") eddate: String = Constants.END_DATE,
        @Query("cpage") cpage: String = Constants.CURRENT_PAGE,
        @Query("rows") rows: String = Constants.PAGE_INDEX,
        @Query("openrun") openrun: String? = null,
        @Query("newsql") newsql: String? = "Y",
        @Query("shcate") shcate: String? = null,
        @Query("kidstate") kidstate: String? = null,
        @Query("prfstate") prfstate: String? = null,
    ): DbsResponse

그 동안 위의 방식으로 정해진 endPoint에 쿼리를 보내고 응답을 받는 방식만 사용해 보다가 이번에 프로젝트를 하면서 다른 쿼리에서 받은 id를 endPoint에 넣어야 할 일이 생겼다. 

 

방법은 동적으로 URI를 가능하게 해주는 Annotation인 @Path 를 사용하는 방법이다. 

 

 @GET(EndPoint), EndPoint에서 중괄호'{ }'로 감싸진 변수에 매핑되도록 알려주는 역할 

@GET("pblprfr/{id}")
    suspend fun fetchShowDetail(
        @Path("id") path: String,
    ): DbsResponse

 

728x90

공공데이터 포털에서 Json이 아닌 xml 을 응답으로 받아야 하는 상황이라서 xml 을 파싱하는 방법을 찾다가 처음에는 TikXml 을 사용 했으며, TikXml 사용시 gradle 충돌 문제가 있어서 다른 방법을 찾아봤다.

 

//빌드에러

Caused by: org.gradle.api.internal.artifacts.ivyservice.DefaultLenientConfiguration$ArtifactResolveException: Could not resolve all files for configuration ':app:debugCompileClasspath'.

 

jaxb 로 대체하려 했으나 몇 시간의 삽질 끝에 android 지원하지 않는 것 알게됨

 

시도 해본 것

  1. annotaion 버전 down grade
  2. 캐시 삭제
  3. 리빌드 프로젝트 -> Could not find com.tickaroo.tikxml:annotation:0.8.15.
    Required by:
    project :app
    project :app > com.tickaroo.tikxml:retrofit-converter:0.8.15 에러
  4. kapt 제거
  5. jaxb 라이브러리 사용 -> 안드로이드 지원x 삽질o

결국 버전을 내려서 다시 시도 

implementation("com.tickaroo.tikxml:annotation:0.8.13")
implementation("com.tickaroo.tikxml:core:0.8.13")
kapt("com.tickaroo.tikxml:processor:0.8.13")
implementation("com.tickaroo.tikxml:retrofit-converter:0.8.13")
kapt("com.tickaroo.tikxml:auto-value-tikxml:0.8.13")

 

버전을 0.8.15 에서 0.8.13으로 내려서 그래들 문제는 해결이 되었는데 데이터가 들어온 이후에 타임아웃 에러가 뜸

 

-> 데이터 클래스 구조와 어노테이션을 잘 못 사용한 것 때문에 파싱이 제대로 안된 것 데이터 클래스 구조와 어노테이션을 제대로 사용해서 파싱 성공

 

KopisApi

package com.nbc.curtaincall.data.model

import retrofit2.http.GET
import retrofit2.http.Query

interface KopisApi {
    @GET("pblprfr")
    suspend fun fetchShowList(
        @Query("stdate") stdate: String = "20240101",
        @Query("eddate") eddate: String = "20240630",
        @Query("cpage") cpage: String = "1",
        @Query("rows") rows: String = "10",
    ): ShowListResponse
}

 

RetrofitClient

package com.nbc.curtaincall.data.api

import com.nbc.curtaincall.BuildConfig
import com.nbc.curtaincall.data.model.KopisApi
import com.tickaroo.tikxml.TikXml
import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit

private const val BASE_URL = "http://kopis.or.kr/openApi/restful/"
private const val KOPIS_API_KEY = BuildConfig.KOPIS_API_KEY

object RetrofitClient {
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY // 로깅 레벨 설정 (BASIC, HEADERS, BODY)
    }

    // API Key 삽입을 위한 인터셉터
    private val apiKeyInterceptor = Interceptor { chain ->
        val original = chain.request()
        val originalHttpUrl = original.url
        val url = originalHttpUrl.newBuilder()
            .addQueryParameter("service", KOPIS_API_KEY)  // Kopis api key 기본 추가
            .build()
        val requestBuilder = original.newBuilder().url(url)
        val request = requestBuilder.build()
        chain.proceed(request)
    }

    // OkHttpClient 설정
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(apiKeyInterceptor)
        .addInterceptor(loggingInterceptor)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(15, TimeUnit.SECONDS)
        .connectTimeout(15, TimeUnit.SECONDS)
        .build()


    // Retrofit 설정
    private val retrofit by lazy {
    	//exceptionOnUnreadXml(false).build() 는 원하지 않는 데이터는 제외하기 위해서입니다. 내려받은 데이터를 모두 사용하신다면 추가하지 않아도 됩니다.
        val parser = TikXml.Builder().exceptionOnUnreadXml(false).build()
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(TikXmlConverterFactory.create(parser))
            .build()
    }

    val kopisApi: KopisApi by lazy { retrofit.create(KopisApi::class.java) }
}

 

ShowListResponse 

package com.nbc.curtaincall.data.model

import com.tickaroo.tikxml.annotation.Element
import com.tickaroo.tikxml.annotation.PropertyElement
import com.tickaroo.tikxml.annotation.Xml

@Xml(name = "dbs")
data class ShowListResponse(
    @Element(name = "db")
    val showList: List<ShowList>
)

@Xml(name = "db")
data class ShowList(
    @PropertyElement(name = "area") val area: String?,
    @PropertyElement(name = "fcltynm") val fcltynm: String?,
    @PropertyElement(name = "genrenm") val genrenm: String?,
    @PropertyElement(name = "mt20id") val mt20id: String?,
    @PropertyElement(name = "openrun") val openrun: String?,
    @PropertyElement(name = "poster") val poster: String?,
    @PropertyElement(name = "prfnm") val prfnm: String?,
    @PropertyElement(name = "prfpdfrom") val prfpdfrom: String?,
    @PropertyElement(name = "prfpdto") val prfpdto: String?,
    @PropertyElement(name = "prfstate") val prfstate: String?
)

 

@PropertyElement는 nest child element가 없는 경우 @Element는 nest child element를 가진 경우

어노테이션 부분은 Json을 파싱할 때랑 비슷하다. 

 

공식문서 참고

https://github.com/Tickaroo/tikxml/blob/master/docs/AnnotatingModelClasses.md

 

 

받아오는 XML 형태 

<?xml version="1.0" encoding="UTF-8"?>
<dbs>
    <db>
        <mt20id>PF236104</mt20id>
        <prfnm>싱어게인3 TOP10 전국투어 [울산]</prfnm>
        <prfpdfrom>2024.06.08</prfpdfrom>
        <prfpdto>2024.06.08</prfpdto>
        <fcltynm>KBS홀 [울산]</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236104_240223_151315.gif</poster>
        <genrenm>대중음악</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236099</mt20id>
        <prfnm>화통콘서트 [거창]</prfnm>
        <prfpdfrom>2024.03.09</prfpdfrom>
        <prfpdto>2024.03.09</prfpdto>
        <fcltynm>거창문화센터</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236099_240223_144636.jpg</poster>
        <genrenm>복합</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236098</mt20id>
        <prfnm>한경arte필하모닉 더클래식 시리즈3, 지휘 이병욱 &amp; 바이올린 윤소영</prfnm>
        <prfpdfrom>2024.03.28</prfpdfrom>
        <prfpdto>2024.03.28</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236098_240223_143315.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236097</mt20id>
        <prfnm>퍼커셔니스트 고길영 &amp; 홍진영 세 번째 듀오 리사이틀</prfnm>
        <prfpdfrom>2024.03.31</prfpdfrom>
        <prfpdto>2024.03.31</prfpdto>
        <fcltynm>대전예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236097_240223_142644.gif</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236096</mt20id>
        <prfnm>코리아아르츠그룹 베스트 아티스트 시리즈, 1. The Best Sopranos</prfnm>
        <prfpdfrom>2024.03.11</prfpdfrom>
        <prfpdto>2024.03.11</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236096_240223_141522.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236095</mt20id>
        <prfnm>지브리 페스티벌</prfnm>
        <prfpdfrom>2024.05.12</prfpdfrom>
        <prfpdto>2024.05.12</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236095_240223_140619.gif</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236094</mt20id>
        <prfnm>지브리 봄 음악 대축전</prfnm>
        <prfpdfrom>2024.04.18</prfpdfrom>
        <prfpdto>2024.04.18</prfpdto>
        <fcltynm>롯데콘서트홀</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236094_240223_140210.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236093</mt20id>
        <prfnm>오페라속 여인들1 [제주 서귀포]</prfnm>
        <prfpdfrom>2024.03.30</prfpdfrom>
        <prfpdto>2024.03.30</prfpdto>
        <fcltynm>서귀포예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236093_240223_135545.jpg</poster>
        <genrenm>서양음악(클래식)</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236090</mt20id>
        <prfnm>프린세스 공주뮤지컬쇼 [청주]</prfnm>
        <prfpdfrom>2024.04.14</prfpdfrom>
        <prfpdto>2024.04.14</prfpdto>
        <fcltynm>청주예술의전당</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236090_240223_133927.jpg</poster>
        <genrenm>뮤지컬</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
    <db>
        <mt20id>PF236088</mt20id>
        <prfnm>기형도 35주기 추모 행사, 기형도 플레이</prfnm>
        <prfpdfrom>2024.03.22</prfpdfrom>
        <prfpdto>2024.03.22</prfpdto>
        <fcltynm>광명시민회관</fcltynm>
        <poster>http://www.kopis.or.kr/upload/pfmPoster/PF_PF236088_240223_132953.jpg</poster>
        <genrenm>연극</genrenm>
        <openrun>N</openrun>
        <prfstate>공연예정</prfstate>
    </db>
</dbs>

 

API 통신응답을 JSON으로 밖에 받아 본 경험 밖에 없어서 XML 을 파싱하는데 많은 시행착오와 씨뻘건 오류 메세지만 이틀간 보면서 씨름 했다. 결국 Json이랑 다를 건 별로 없는데, Xml을 파싱하는 과정이 순조롭지 못 했고 TikXml이 2020년에 commit 된게 마지막이라고 한다. 

 

안드로이드에서는 XmlPullParser 사용을 추천하는 것 같은데 Retrofit을 사용하지 않는 것 같아서 다음에 한 번 알아봐야 겠다.

 

 

728x90

Git으로 노출하지 않을 경로를 지정하기 위해 gitignore 에 경로를 지정하면, 해당 경로는 더 이상 Git이 변경사항을 관찰하지 않는다.

1. gitignore

가장 먼저 Git으로의 노출을 막아야 할 것은 local.properties 이다. 여기에 API 키를 저장시킨다.

/build 경로를 같이 확인해준다. 해당 경로에는 BuildConfig 가 존재하는데,  여기에서 API 키를 변수로 저장 하게 될 것이다. 역시 버전 컨트롤을 할 때 절대 포함 시켜서는 안된다.

.gitignore 에서 local.properties 확인

2. local.properties

Gradle Scripts local.properties 로 들어가 API 키나 기타 중요한 데이터들을 저장한다.

이 때 주의할 점은 꼭 큰 따옴표(" ") 로 변수를 저장하고, 띄어쓰기는 하지 않는다.

local.properties

3. build.gradle (app)

앱 수준의 그래들 파일에서 local.properties 에 새로 등록한 키값을 불러올 차례다.

 

KTS에는 총 2가지 방식이 존재한다.

1. Properties 객체 생성 방법

2. local.properties 내부에서 key값을 가져오는 함수 구현  

두 가지 중 한가지 아무거나 사용 

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}
// 1. Properties 객체 생성 방식
val properties = Properties().apply {
    load(FileInputStream(rootProject.file("local.properties")))
}
android {
    namespace = "com.~"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.~"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        
        // 프로젝트 내에서 언제든지 사용이 가능한 변수들이다.
        // 타입 - 키 - 값으로 저장된다.
        buildConfigField "String", "GOOGLE_API_KEY", properties['google_api_key']//방식1
        buildConfigField("String", "KOPIS_API_KEY", getAuthKey("KOPIS_API_KEY"))//방식2
        
        // 매니페스트에서 사용이 가능하다. 구글맵 같은 경우가 이에 해당한다.
        // 키 - 값으로 저장된다.
        manifestPlaceholders = [GOOGLE_API_KEY: google_api_key]
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        viewBinding = true
        //8.0.0부터는 buildConfig를 막았기 때문에 buildFeatures에 추가해줘야 한다. 
        buildConfig = true
    }
}
//2. local.properties 내부에서 key값을 가져오는 함수 구현방식
fun getAuthKey(propertyKey: String): String =
    gradleLocalProperties(rootDir).getProperty(propertyKey)
    
    . . . (중략)

 

buildConfigField 에서 저장한 API 키와 이어주고, 그래들을 빌드해주면 BuildConfig 에 저장되어 프로젝트 내에서 언제든 사용할 수 있게 된다. (적용이 안되어있다면 Rebuild Project)

4. 사용

우선 매니페스트 영역에서 사용되는 key 

<application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.AOB"
        
    . . .

    <meta-data
       android:name="com.google.android.geo.API_KEY"
       android:value="${GOOGLE_API_KEY}" />
            
    . . .
        
</application>

 

구글 맵은 매니페스트에서 API 키를 선언해야 하는데, 저렇게 "${변수}" 명으로 선언해주면 된다. 

 

private const val API_KEY = BuildConfig.KOPIS_API_KEY 이런 방식으로 사용해주면 된다. 

Log 확인

 

728x90

이전에 기획했던 주차마침은 기획단계에서 치명적이라고 판단되는 문제들이 있어서 아이디어 회의부터 다시 시작했다. 

아이디어 회의를 거치고 도출된 결과가 공연 정보를 제공하는 서비스 이다. 

프로젝트명 : 커튼콜

목적 : 공연 정보 제공

소개 : 상영예정과 현재 상영중인 공연 정보를 제공해주는 앱

주 타겟층 : 공연을 좋아하는 누구나 (남녀노소)

필수핵심 기능

  • 상영예정 및 현재 상영중인 공연정보 제공 (날짜지정)
  • 기대평 및 실제 공연 관람 후기 (데이터베이스에 저장?)
  • 공연 검색 기능(키워드) - 필터여부는 나중에
  • 로그인 기능 (내정보 저장, 설정정보 저장, 프로필저장)

추가 기능

  • 관심있는 분야 및 북마크에 따라 공연 추천 (관심 분야선택, 챗 GPT ?)
  • 어린이 시청가능 공연만 따로 분리제공 (카테고리??)
  • 결제기능x 결제 가능 사이트로 연결되도록 (웹뷰)
  • 성인인증(19세미만에게 부담되는 썸네일 , 성인연극 제외) (19금표시)
  • 공연장위치 지도로 표시

 

화면별로 구성

스플래시 화면

커튼이 사르륵 닫히는 화면

홈 화면

→ 칩메뉴로 장르 선택 가능(선택시 아래 화면들의 아이템이 바껴서 보여짐)

→ 탑10

→ 상영중

→ 상영예정

간단 정보 화면(바텀)

→ 공연이름

→ 공영장 위치

→ 예매율

→ 평점

상세화면

→ 공연의 상세 정보들과 리뷰리스트, authenticated user에게 리뷰 입력란이 보여서 리뷰 입력이 가능하도록

→ 상세정보 목록은 무엇으로?

→ 상세정보

→ 공연장 위치 (맵까지)

→ 리뷰

→ 예매율 (남녀노소 비율)

검색화면

→ 필터 (장르, 기간, 금액) 선택하거나

→ 지정한 검색어에 따라 검색결과 화면 뿌려주기

나의정보화면

→ 프로필 (내이름, 닉넴, 이미지)

→ 나의 관심 영역 (공연 즐겨찾기)

→ 예약정보? : 추후에 추가 가능할시

로그인 화면

→ 아이디 비번

→ 소셜 로그인?(카카오, 네이버, 구글)

회원가입화면

→ ???

 

기획단계를 계속 거치고 있는데 정말 너무너무너무너무너무 어렵다. 

+ Recent posts