안드로이드 트리 구조 리사이클러뷰 구현 (접었다 폈다) [Android, JAVA]
반응형

트리 구조로 리사이클러뷰를 사용하고 싶은 경우가 있다.

각 가게들의 쿠폰을 저장하기 위해서, 각 가게 - 각 쿠폰의 트리 구조로 리사이클러뷰를 구현하고자 했다.

가장 간단한 방법은 리사이클러뷰에 붙이는 아이템의 종류를 헤더(가게) / 자식(쿠폰)으로 나눠서 역할을 분리하는 것이다.

하나의 리사이클러뷰에 [동일 클래스] 가게-쿠폰-쿠폰-쿠폰-가게-쿠폰-쿠폰.... 로 해버리는 것이다.

 

쿠폰 객체 구성

일단 사용할 쿠폰에 관련된 데이터를 들고 있는 클래스를 선언하자. 쿠폰 id, 가게명, 쿠폰명 등등을 필드로 갖고 있다.

@Getter
@Setter
@AllArgsConstructor
@Builder
public class CouponModel{
    long couponId;
    String storeName;
    String couponName;
    String couponCode;
    int minimumOrderPrice;
    String startDate;
    String endDate;
    String expiryDate;
    CouponType couponType;
    int fixAmount;
    Double rateAmount;
}

 

 

그리고 이 쿠폰 클래스를 포함하여 리사이클러뷰에 붙어 역할을 수행할 클래스를 선언한다.

dataType이라는 변수를 통해 객체가 헤더인지, 자식인지 구분해준다.

또한, ArrayList를 선언하여 만약 헤더(가게)면 자식(쿠폰들)의 리스트를 들고 있게 한다.

이는 접었다 - 폈다를 구현할 때, 접은 경우 사라지는 자식들을 들고 있기 위함이다.

@Getter
@Setter
@AllArgsConstructor
public class ExpandableCouponData {
    //0 - 헤더, 1 - 자식
    int dataType;
    //쿠폰 데이터
    CouponModel couponModel;
    ArrayList<ExpandableCouponData> invisibleChild;

    public ExpandableCouponData(int dataType, String storeName){
        this.dataType =dataType;
        this.couponModel = new CouponModel(storeName);
    }

    public ExpandableCouponData(int dataType, CouponModel couponModel){
        this.dataType =dataType;
        this.couponModel = couponModel;
    }
}

 

뷰 구성

역시 헤더(가게) 타입과 자식(쿠폰)타입이 보여질 각 아이템 뷰도 만들어놔야 한다.

(XML 코드 보기 ▽)

더보기
<!--부모 (헤더) -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:paddingLeft="13dp"
    android:paddingStart="13dp"
    android:paddingRight="10dp"
    android:paddingEnd="10dp"
    android:paddingTop="5dp"
    android:paddingBottom="5dp"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <TextView
        android:id="@+id/header_title"
        android:textColor="#045CB1"
        android:textSize="18sp"
        android:gravity="center_vertical"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"/>
    <ImageView
        android:id="@+id/btn_expand_toggle"
        android:src="@drawable/ic_baseline_arrow_drop_down_24"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_weight="0"/>
</LinearLayout>
<!--자식 (쿠폰) -->
<?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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="5dp"
    android:background="@drawable/drawable_round_background"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout3"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:background="@drawable/drawable_round_background_stroke"
        android:backgroundTint="@color/main_400"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <Button
            android:id="@+id/button_watchCode"
            android:layout_width="wrap_content"
            android:layout_height="0dp"
            android:minWidth="0dp"
            android:text="코드"
            app:icon="@drawable/ic_baseline_sync_24"
            app:iconGravity="textTop"
            app:iconTint="@color/white"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <LinearLayout
        android:id="@+id/linearLayout_coupon_code"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:background="@color/white"
        android:gravity="center"
        android:orientation="horizontal"
        android:paddingLeft="20dp"
        android:paddingTop="10dp"
        android:paddingRight="10dp"
        android:paddingBottom="10dp"
        android:visibility="invisible"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/constraintLayout3"
        app:layout_constraintTop_toTopOf="parent">


        <TextView
            android:id="@+id/textView_couponCode"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:text="쿠폰코드"
            android:textColor="@color/text_400"
            android:textSize="24sp" />

    </LinearLayout>

    <LinearLayout
        android:id="@+id/linearLayout_coupon_info"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:background="@color/white"
        android:orientation="vertical"
        android:paddingLeft="20dp"
        android:paddingTop="5dp"
        android:paddingRight="10dp"
        android:paddingBottom="5dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/constraintLayout3"
        app:layout_constraintTop_toTopOf="parent">


        <TextView
            android:id="@+id/textView_couponName"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="쿠폰명"
            android:textSize="14sp" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/textView_couponAmount"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="20"
                android:textSize="16sp"
                android:textStyle="bold" />

            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text=" 할인"
                android:textSize="16sp"
                android:textStyle="bold" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/linerlayout_due"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/textView_couponExpire"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="2000/00/00"
                android:textSize="@dimen/search_shop_description" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/linerlayout_minimum"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/textView_minimum"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="10000"
                android:textSize="@dimen/search_shop_description" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="원 이상 결제 시 사용 가능"
                android:textSize="@dimen/search_shop_description" />
        </LinearLayout>

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
 
데이터 정리

결국 하나의 리사이클러뷰에 두 타입의 데이터를 가게-쿠폰-쿠폰-가게-쿠폰-쿠폰처럼 하나의 리스트로 넣기 때문에 데이터 역시 미리 위와 같은 타입으로 정리해놔야 한다. (미리 해놨으면 안해도 됨)

public ArrayList<ExpandableCouponData> parser(){

    LinkedHashMap<String, ArrayList<CouponModel>> map = new LinkedHashMap<>();
    ArrayList<ExpandableCouponData> parse = new ArrayList<>();

    //1. 가게 이름별에 따라 쿠폰 정리
    for (CouponModel c : myCoupons.getValue()){
        if(!map.containsKey(c.getStoreName())){
            map.put(c.getStoreName(), new ArrayList<>());
        }
        map.get(c.getStoreName()).add(c);
    }

    //2. 리스트로 정리
    for(String storeName : map.keySet()){
        //0
        parse.add(new ExpandableCouponData(MysosoCouponAdapter.HEADER,storeName));

        for(CouponModel c : map.get(storeName)){
            //1
            parse.add(new ExpandableCouponData(MysosoCouponAdapter.CHILD, c));
        }
    }
    return parse;
}

 

리사이클러뷰 어댑터

이제 어댑터에 ExpandableCouponData를 붙이되, 각각의 객체를 헤더인지 / 자식인지 구분해서 붙여주면 된다.

이를 위해, ViewHolder를 상속받는 두 타입의 HeaderViewholder, ChildViewholder를 따로 선언해서 역할을 분리시켜주면 된다.

바인딩 역시 위에 선언한 두 타입에 뷰에 맞는 바인딩객체로 각각의 뷰 홀더에 맞게 바인딩하면 된다.

이때 헤더타입의 데이터는 자식타입에서 요구하는 데이터가 없기 때문에 잘 나눠주지 못하면 null오류가 터지게 된다.

public class MysosoCouponAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    public static final int HEADER = 0;
    public static final int CHILD = 1;

    ArrayList<ExpandableCouponData> items;

    ItemMysosoCouponHeaderBinding headerBinding;
    ItemMysosoCouponBinding childBinding;

    public MysosoCouponAdapter(ArrayList<ExpandableCouponData> items) {
        this.items = items;
    }

    public void setItems(ArrayList<ExpandableCouponData> items){
        this.items = items;
    }

    @NonNull
    @NotNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull @NotNull ViewGroup parent, int viewType) {

        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        //두 가지 타입의 뷰홀더 리턴
        switch (viewType){
            case HEADER:{
                headerBinding = ItemMysosoCouponHeaderBinding.inflate(inflater, parent, false);
                return new HeaderViewHolder(headerBinding);
            }
            case CHILD:{
                childBinding = ItemMysosoCouponBinding.inflate(inflater, parent, false);
                return new ChildViewHolder(childBinding);
            }
        }
        return null;
    }

    @Override
    public void onBindViewHolder(@NonNull @NotNull RecyclerView.ViewHolder holder, int position) {
        final ExpandableCouponData item = items.get(position);
        //아이템 바인딩도 헤더/자식 구분해서 다른 뷰 홀더로 바인딩
        switch (item.getDataType()){
            case HEADER:{
                 ((HeaderViewHolder) holder).bindItem(items.get(position), position);
                 break;
            }
            case CHILD:{
                ((ChildViewHolder) holder).bindItem(items.get(position));
                break;
            }
        }
    }

    @Override
    public int getItemCount() {
        return items.size();
    }
    @Override
    public int getItemViewType(int position) {
        return items.get(position).getDataType();
    }

    //헤더 ViewHolder
    public class HeaderViewHolder extends RecyclerView.ViewHolder{
        ItemMysosoCouponHeaderBinding binding;

        public HeaderViewHolder(ItemMysosoCouponHeaderBinding binding) {
            super(binding.getRoot());
            this.binding =binding;
        }

        //나중에 표시 / 숨김 처리를 위해 포지션 int 값도 받기
        public void bindItem(ExpandableCouponData item, int pos){
            //가게명
            binding.headerTitle.setText(item.getCouponModel().getStoreName());
        }
    }

    //자식 ViewHolder
    public class ChildViewHolder extends RecyclerView.ViewHolder{
        ItemMysosoCouponBinding binding;
        boolean state = true;

        public ChildViewHolder(ItemMysosoCouponBinding binding) {
            super(binding.getRoot());
            this.binding = binding;

            binding.buttonWatchCode.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    if(state){
                        binding.linearLayoutCouponCode.setVisibility(View.VISIBLE);
                        binding.linearLayoutCouponInfo.setVisibility(View.INVISIBLE);
                    }
                    else{
                        binding.linearLayoutCouponCode.setVisibility(View.INVISIBLE);
                        binding.linearLayoutCouponInfo.setVisibility(View.VISIBLE);
                    }
                    state = !state;
                }
            });
        }
        //자식 바인딩
        public void bindItem(ExpandableCouponData item){
            CouponModel couponModel = item.getCouponModel();

            binding.textViewCouponCode.setText(couponModel.getCouponCode());
            binding.textViewCouponAmount.setText(couponModel.amount());

            //코드, 이름
            binding.textViewCouponName.setText(couponModel.getCouponName());

            //제한조건
            binding.textViewMinimum.setText(Integer.toString(couponModel.getMinimumOrderPrice()));

            if(couponModel.getExpiryDate() != null){
                binding.textViewCouponExpire.setText("저장 후 "+DateFormatMethod.dateFormatDay(couponModel.getExpiryDate())+"까지 사용가능");
            }

        }
    }
}

 

접었다 펴기 (숨김 / 표시) 처리

이제 헤더(가게)에서 접었다-폈다 할 수 있도록 구성해보자.

어차피 다 같은 리사이클러뷰에 붙어 있는 애들이니깐 원리는 간단하다.

  1. 접어두기를 하면 -> 해당 헤더의 좌표 이후의 자식들을 다 지우고(따로 저장해두고)
  2. 펼치기를 하면 -> 해당 헤더 좌표 이후의 자식들을 다 다시 붙이면 된다.
//헤더 뷰홀더
public class HeaderViewHolder extends RecyclerView.ViewHolder{
    ItemMysosoCouponHeaderBinding binding;

    public HeaderViewHolder(ItemMysosoCouponHeaderBinding binding) {
        super(binding.getRoot());
        this.binding =binding;
    }

    public void bindItem(ExpandableCouponData item, int pos){
        binding.headerTitle.setText(item.getCouponModel().getStoreName());

        public void bindItem(ExpandableCouponData item, int pos){
            binding.headerTitle.setText(item.getCouponModel().getStoreName());

            //버튼 설정 (아래삼각형, 위삼각형)
            if(item.getInvisibleChild() == null){
                binding.btnExpandToggle.setImageResource(R.drawable.ic_baseline_arrow_drop_down_24);
            }else{
                binding.btnExpandToggle.setImageResource(R.drawable.ic_baseline_arrow_drop_up_24);
            }

            binding.btnExpandToggle.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    //숨은자식 x에서 눌림 -> 접어두기
                    if (item.getInvisibleChild() == null) {
                        item.setInvisibleChild(new ArrayList<ExpandableCouponData>());
                        int count = 0;
                        //다음 헤더 이전까지 모두 getInvisibleChild()에 저장
                        while (items.size() > pos + 1 && items.get(pos + 1).dataType == CHILD) {
                            item.getInvisibleChild().add(items.remove(pos + 1));
                            count++;
                        }
                        //이 사이 아이템 바뀌었다고 통지
                        notifyItemRangeRemoved(pos + 1, count);
                        binding.btnExpandToggle.setImageResource(R.drawable.ic_baseline_arrow_drop_up_24;
                    } 
                    //펼치기
                    else {
                        int index = pos + 1;
                        //현재 좌표 이후로 다시 아이템 붙이기
                        for (ExpandableCouponData i : item.getInvisibleChild()) {
                            items.add(index, i);
                            index++;
                        }
                        //이 사이 아이템 바뀌었다고 통지
                        notifyItemRangeInserted(pos + 1, index - pos - 1);
                        binding.btnExpandToggle.setImageResource(R.drawable.ic_baseline_arrow_drop_down_24);
                        //숨긴 자식 목록 비우기
                        item.setInvisibleChild(null);
                    }
                }
            });
        }
    }
}

 

결과

요로코롬 각 가게에 맞게 쿠폰을 접었다 폈다 할 수 있다.

전체적인 어플리케이션 코드는 다음 레포를 확인하면 된다.

 

GitHub - howtolivelikehuman/sososhopping_app_customer: 소소한 장보기 고객용 애플리케이션

소소한 장보기 고객용 애플리케이션. Contribute to howtolivelikehuman/sososhopping_app_customer development by creating an account on GitHub.

github.com

 

전문가가 아니라 정확하지 않은 지식이 담겨있을 수 있습니다.
언제든지 댓글로 의견을 남겨주세요!

 

 

 

반응형