Android - implementing RollingBanner (롤링 배너)

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2017-10-+08

언뜻 보면, 대부분 메인 또는 어딘가에 롤링 배너 형식 같은 UX를 넣는 앱이 꽤나 많은 것 같다.

당장 회사 들어와서 한 70% 이상의 프로젝트엔 배너 형식이 꼭 들어갔는데, 단순히 배너 형식이 아니라

  • 마지막 위치로 갔을 때 다시 스크롤 하면 첫 위치
  • 자동 스크롤
  • 스크롤 속도 조절
  • 인디케이터

가 포함되어 있는 롤링 배너 인 경우가 많다.

기존까지는 모듈화를 안 시켜놓고 썼었는데, 연휴 기간이 생각보다 길어서 모듈화를 시켜두었다.

스크린샷은 아래와 같다.

실제 구동 영상은 이 쪽에서 볼 수 있다.

0. 구조

일단 사용자가 스크롤 할 수 있어야 되니 ViewPager, 인디케이터는 직접 구현하고 그 두개를 다시 감싸는 형태로 만들면 될 것 같다. 이름은 'RollingBanner' 로, 약 4개 클래스만 필요할 것 같다.

  • RollingBanner - RollingViewPager, RollingViewPagerIndicator 관리 커스텀 뷰
  • RollingViewPager - '자동 스크롤', '스크롤 속도 조절', '사용자 터치 막기' 대응용으로 ViewPager를 상속받은 뷰
  • RollingVIewPagerIndicator - 각각 이미지 리소스, 여백 등을 받고 ViewPager를 설정하면 해당 ViewPager의 실제 아이템 갯수 만큼 이미지를 넣고, 스크롤 될 때 마다 selected 반복하는 역할을 할 커스텀 뷰
  • RollingViewPagerAdapter - 사실상의 무한대로 아이템을 관리할 커스텀 어댑터 클래스

그리고 주의할 점은 아래와 같다.

  • 필요한 기능을 전부 구현하면서도, 실제 사용하는 코드는 매우 적게
  • Kotlin 을 사용할 것이기 때문에, Java Interop 도 따져서.

1. RollingViewPagerAdapter

맨 먼저, ViewPager에 가장 중요한 어댑터 클래스를 만드는 일이다.

기본적으로 전부 wrap하면서 경우마다 달라지는 것들을 상속받아서 처리하게 할 것이기 때문에, 추상 클래스로 만든다.

무한 스크롤을 구현하려면 아래 3가지 부분을 고려해야 한다.

  • getCount 에는 실제 아이템 수 * 100000 (예시) 가 들어가기 때문에, 실제 아이템 수에 기반한 실제 위치를 가지고 꺼내써야 함
  • instantiateItem, destroyItem 등을 적절히 구현해서 메모리 이슈가 나지 않도록 함
  • 생성자에서 아이템 리스트를 받아야 처리가 매우 편할 것이다. (동적 생성... 은 지금은 고려하지 말자.) 사용자가 어떤 객체를 가지고 롤링 배너를 사용할 지 모르므로 Generic 를 사용한다.

첫 번째 사항의 경우 getRealPosition 같은 메서드로 현재 페이지 (currentItem) 을 넘기면 실제 아이템 갯수를 리턴하게 하면 된다.

아이템 갯수가 5개면, 6 일때는 1을 리턴, 12일 때는 2를 리턴 하는 등 이런 식이면 된다.

fun getRealPosition(page: Int) = page % realCount

두번째 사항의 경우, 적절히 구현해보자.

override fun instantiateItem(container: ViewGroup, position: Int): Any? {
        return if (!this.itemList.isEmpty()) {
            val v = this.getView(getRealPosition(position))
            container.addView(v)
            v
        } else {
            null
        }
    }

override fun destroyItem(container: ViewGroup, position: Int, \`object\`: Any) {
        container.removeView(\`object\` as View)
}

instantiateItem 에는 실제 아이템 리스트가 비어있는지 체크하고 getView 라는 추상 메소드로 View를 얻어 ViewGroup에 추가시킨다.

destroyItem 에는 파괴할 뷰가 object 란 이름으로 올테니 그걸 제거하는 역할을 한다.

2. RollingViewPager

여기서는 주어진 시간마다 스크롤, 사용자 스크롤 막기, 스크롤 속도 조절을 담당할 것이다.

주어진 시간마다 스크롤은 매우 간단하니 코드만 남긴다.

private val autoScrolling = Runnable {
        setCurrentItem(currentItem + 1, smoothScroll)
        startAutoScrolling()
}

private fun startAutoScrolling() {
        this.scrollHandler.removeCallbacks(this.autoScrolling)
        this.scrollHandler.postDelayed(this.autoScrolling, this.delay)
}

사용자 스크롤은, 원래 ViewPager는 사용자가 넘길 수 있도록 구성된 위젯이나 스크롤을 막아달라고 하는 요청이 몇 번 있었다.

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
      return try {
          this.flingAble && super.onInterceptTouchEvent(event)
      } catch (var3: Exception) {
          false
      }
  }

override fun onTouchEvent(arg0: MotionEvent): Boolean {
      return this.flingAble && super.onTouchEvent(arg0)
  }

그래서 onInterceptTouchEvent 와 onTouchEvent 를 오버라이드 해서 boolean 하나로 제어할 수 있게 한다.

스크롤 속도 조절이라 함은, 사용자가 넘길 때 넘어가는 속도거나 자동으로 넘어갈 때 넘어가는 속도를 조절하는 것이다. 즉, A -> B 에서 넘어갈 때 A와 B 사이의 이동 부분이다.

이쪽은 Reflection 을 사용해서 mScroller 라는 ViewPager에 선언된 private 필드에 상속받아 개조한 Scroller 객체를 넣는다.

internal fun setScrollingDelay(millis: Int) {
        tryCatch {
            val viewpager = ViewPager::class.java
            val scroller = viewpager.getDeclaredField("mScroller")
            scroller.isAccessible = true
            scroller.set(this, DelayScroller(context, millis))
        }
}

private inner class DelayScroller(context: Context, val durationScroll: Int = 250) 
    : Scroller(context, DecelerateInterpolator()) {

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int) {
            super.startScroll(startX, startY, dx, dy, durationScroll)
        }

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, durationScroll)
        }
}

3. RollingViewPagerIndicator

인디케이터를 직접 구현한다고 해도, 신경쓸 점은 그렇게 많지는 않다.

  • 이미지 리소스, 인디케이터 원 사이의 여백, ViewPager 객체를 설정할 수 있는 public 메소드
  • ViewPager의 페이지 이동이 감지될 때 마다 (onPageSelected) 해당하는 인디케이터의 상태를 변화시킴
    • 여기서는 간단하게 Selector로 구성한다.
  • 어댑터가 설정된 ViewPager 객체를 받아야 그 안에 있는 realCount 가 제대로 작동할 것임
  • 제대로 된 ViewPager 객체가 내려오면 실제 아이템 갯수 만큼 반복을 돌려서 이미지 뷰를 생성, 마지막 아이템이 아닐 경우에만 오른쪽 여백을 주고 인디케이터를 클릭하면 그 페이지로 이동함

일단 public 메소드 부터 만들어보자.

fun setIndicatorResource(resId: Int, margin: Int) {
        this.margin = margin
        this.resId = resId
}

fun setViewPager(viewPager: ViewPager) {
        this.viewPager = viewPager

        viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {

            }

            override fun onPageSelected(position: Int) {
                selectIndicator(adapter.getRealPosition(position))
            }

            override fun onPageScrollStateChanged(state: Int) {

            }
        })
}

그다음 페이지 이동이 감지될 때 마다 selectIndicator 란 메서드를 실행하도록 하는데, 여기서 넘길 position 값은 실제 아이템 갯수에 기반한 위치 값이여야 한다.

fun selectIndicator(position: Int) {
       for (i in 0 until childCount) {
           val child = getChildAt(i)
           child.isSelected = i == position
       }
   }

마지막으로 제대로 된 ViewPager가 설정될 경우에 이미지 뷰를 추가하는 부분이다.

fun notifyDataSetChanged() {
        removeAllViews()
        if (adapter.realCount < 2) {
            visibility = View.GONE
        } else {
            visibility = View.VISIBLE
            addIndicator()
        }
    }

private fun addIndicator() {
        val currentPosition = adapter.getRealPosition(viewPager.currentItem)
        for (i in 0 until adapter.realCount) {
            val imageView = ImageView(context)
            if (resId == View.NO_ID) {
                imageView.setImageResource(R.drawable.default_indicator)
            } else {
                imageView.setImageResource(resId)
            }

            if (currentPosition == i) {
                imageView.isSelected = true
            }

            if (i != adapter.realCount - 1) {
                val params = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
                params.rightMargin = margin
                imageView.layoutParams = params
            }

            imageView.setOnClickListener { viewPager.currentItem = i }
            addView(imageView)
        }
    }

4. RollingBanner

이제 위 3개를 전부 관리하는 커스텀 뷰를 만든다. 옵션 값 조절은 XML로도, 자바로도 할 수 있게 생성자가 트리거 되는 순간 TypedArray 로 접근해 xml 속성을 읽어오고, 각각에 대해 설정하는 public 메서드를 만들어주면 된다. 물론 각각에 대한 기본 값을 넣어 값이 없어도 기본 형태는 보여지게 해야한다.

이렇게 까지 하면 사실상 완성이다.

그러면 실제 라이브러리 외부에서 작성해야 될 코드는 얼만큼 될까.

private String[] txtRes = new String[]{"Purple", "Light Blue", "Cyan", "Teal", "Green"};
private int[] colorRes = new int[]{0xff9C27B0, 0xff03A9F4, 0xff00BCD4, 0xff009688, 0xff4CAF50};

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

        RollingBanner rollingBanner = findViewById(R.id.banner);

        SampleAdapter adapter = new SampleAdapter(new ArrayList<>(Arrays.asList(txtRes)));
        rollingBanner.setAdapter(adapter);
    }

public class SampleAdapter extends RollingViewPagerAdapter<String> {

        public SampleAdapter(ArrayList<String> itemList) {
            super(itemList);
        }

        @Override
        public View getView(int position) {
            View view = LayoutInflater.from(MainActivity.this).inflate(R.layout.activity_main_pager, null, false);
            FrameLayout container = view.findViewById(R.id.container);
            TextView txtText = view.findViewById(R.id.txtText);

            String txt = getItem(position);
            int index = getItemList().indexOf(txt);
            txtText.setText(txt);
            container.setBackgroundColor(colorRes[index]);
            return view;
        }
    }

샘플 앱이니 표시할 데이터는 코드에 넣어두었으나 RollingViewPagerAdapter 자체는 제너릭을 허용하므로 JSONObject의 리스트를 넘겨서 표시하게 하는 등의 작업이 가능할 것이다.

결론

중간에 스튜디오 자체가 먹통이 간 적이 몇 번 있어서 그만둘까도 생각했지만 나름대로 잘 만들어 진 것 같다.

여기까지 만든 코드는 전부 Github에 배포되어 있다. https://github.com/WindSekirun/RollingBanner

자, 그러면 다음엔 어떤 기능을 모듈화 시켜놔야 편하려나...

profile
Android Developer @kakaobank

0개의 댓글