이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.
작성 시점: 2017-10-+08
언뜻 보면, 대부분 메인 또는 어딘가에 롤링 배너 형식 같은 UX를 넣는 앱이 꽤나 많은 것 같다.
당장 회사 들어와서 한 70% 이상의 프로젝트엔 배너 형식이 꼭 들어갔는데, 단순히 배너 형식이 아니라
가 포함되어 있는 롤링 배너 인 경우가 많다.
기존까지는 모듈화를 안 시켜놓고 썼었는데, 연휴 기간이 생각보다 길어서 모듈화를 시켜두었다.
스크린샷은 아래와 같다.
실제 구동 영상은 이 쪽에서 볼 수 있다.
일단 사용자가 스크롤 할 수 있어야 되니 ViewPager, 인디케이터는 직접 구현하고 그 두개를 다시 감싸는 형태로 만들면 될 것 같다. 이름은 'RollingBanner' 로, 약 4개 클래스만 필요할 것 같다.
그리고 주의할 점은 아래와 같다.
맨 먼저, ViewPager에 가장 중요한 어댑터 클래스를 만드는 일이다.
기본적으로 전부 wrap하면서 경우마다 달라지는 것들을 상속받아서 처리하게 할 것이기 때문에, 추상 클래스로 만든다.
무한 스크롤을 구현하려면 아래 3가지 부분을 고려해야 한다.
첫 번째 사항의 경우 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 란 이름으로 올테니 그걸 제거하는 역할을 한다.
여기서는 주어진 시간마다 스크롤, 사용자 스크롤 막기, 스크롤 속도 조절을 담당할 것이다.
주어진 시간마다 스크롤은 매우 간단하니 코드만 남긴다.
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)
}
}
인디케이터를 직접 구현한다고 해도, 신경쓸 점은 그렇게 많지는 않다.
일단 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)
}
}
이제 위 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
자, 그러면 다음엔 어떤 기능을 모듈화 시켜놔야 편하려나...