홈 화면

gang_shik·2022년 6월 19일
0

프로젝트 Fit-In

목록 보기
6/10

홈화면 시나리오

  • 앞서 지금까지 회원가입 & 로그인에 대해서 진행을 하여 서버와 정상적으로 통신을 하고 구조개선까지 이루어졌음

  • 앞으로 개발하는 프로젝트에 있어서 서버에서 주제 컨셉상 API 통신을 통해서 데이터를 받아와서 처리할 것인데, 여러 데이터를 사용자에게 보여주고 즐겨찾기와 같은 기능을 담아서 처리할 것임

  • 그리고 그 내용에 대해서 리스트 형태로 다양한 데이터를 활용해서 사용자에게 제공할 것인데 이 보여주는 View에 있어서 RecyclerView를 활용함, 그리고 사실상 주로 많이 쓰이게 될 View 중에 하나로써 이 부분에 대해서 API 데이터를 받아와서 RecyclerView에서 보여준 뒤 아이템 클릭시 관련 내용에 대해서 세부내용을 보여주는 방식으로 처리할 것임

RecyclerView

  • RecyclerView는 이미지나 텍스트를 리스트화해서 스크롤해서 보여줄 수 있게 해주는 컨테이너인데 이렇게 주로 리스트화해서 보여줄 것이 많아서 차용을 함

  • 여기서 RecyclerView의 경우, 성능적 측면에서 View를 재활용하기 때문에 대중적으로 쓰는 RecyclerView를 사용함

  • 먼저 RecyclerView와 관련해서 item_view에 대해서 아래와 같이 만듬, 데이터를 보여줄 부분임

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>
        <variable
            name="property"
            type="com.example.fitin_v2.model.NewsListResponseDto" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="6dp">

        <TextView
            android:id="@+id/tv_press"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:textColor="@color/black"
            android:textSize="16sp"
            android:textStyle="bold"
            android:text="@{property.press}"
            app:layout_constraintBottom_toTopOf="@id/tv_title"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            />

        <TextView
            android:id="@+id/tv_title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dp"
            android:textSize="20sp"
            android:text="@{property.title}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tv_press"
            />


    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
  • 여기서 item_view에 있어서 조금 다른 부분이 있는데 바로 data binding을 활용한 것임, 일반적으로는 RecyclerView에서 보여줄 List에 대한 item만을 간단하게 설계를 하지만 현재 Data Binding을 적용하고 있기 때문에 이를 응용함

  • 이렇게 Data Binding을 활용하게 된다면 기존의 RecyclerView를 사용할 때 Adapter 내부에서 View에 data를 bind하는 부분을 굳이 View를 불러올 필요도 없고 xml 상에서 해당 Dto를 바로 적용시켜주면 그 data가 알아서 binding 되기 때문에 상당히 편리하게 bind를 할 수 있ㅇ므

  • 그리고 아래와 같이 RecyclerView를 보여줄 부분에 대해서 xml에 추가함

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

    <data>
        <variable
            name="viewModel"
            type="com.example.fitin_v2.ui.home.HomeViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.home.HomeFragment">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_newsList"
            android:layout_width="0dp"
            android:layout_height="0dp"
            tools:listitem="@layout/list_item_news"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:listData="@{viewModel.newsList}"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

DiffUtil & ListAdapter

  • 본격적으로 RecyclerView를 적용시키기에 앞서 View에 대해서 XML 설계를 완료하였는데 이제 이를 확실히 적용시킬 것인데 그 전에 앞서 비교할 부분이 있음

  • 일반적으로 RecyclerView를 만드는 데 있어서 RecyclerView.Adapter를 활용하고 이 RecyclerView에서 데이터 갱신 등 처리할 때 직접 아이템 갱신과 관련한 메소드를 처리해 렌더링 하는 방식이 일반적인 방법이었음

  • 하지만 이 부분에 있어서 앞으로 받아야 할 데이터와 처리할 데이터에 있어서 서버에서 계속 갱신이 되기 때문에 단순하게 기초적으로 RecyclerView 처리를 하면 불러오는데 있어서 시간이 걸릴 수 있다고 판단을 했음, 그리고 이렇게 되면 RecyclerView를 불러오는 곳에서 혹은 ViewModel에서라도 계속 갱신하는 로직이 진행된다고 판단을 함

  • 그래서 이 부분을 DiffUtil을 활용해서 이전 데이터 상태와 현재 데이터간의 상태 차이를 업데이트하는 방향으로 처리를 함

  • 아래와 같이 DiffUtil 클래스를 만들어서 적용함

public class HomeDiffUtil extends DiffUtil.ItemCallback<NewsListResponseDto> {

    @Override
    public boolean areItemsTheSame(@NonNull NewsListResponseDto oldItem, @NonNull NewsListResponseDto newItem) {
        return oldItem.equals(newItem);
    }

    @Override
    public boolean areContentsTheSame(@NonNull NewsListResponseDto oldItem, @NonNull NewsListResponseDto newItem) {
        return oldItem.getId().equals(newItem.getId());
    }
}
  • 그래서 매번 데이터 변경에 대해서 notify를 일일이 하지 않게 처리를 함

  • 그리고 자연스럽게 DiffUtil을 활용함에 따라 RecyclerView.Adapter가 아닌 ListAdapter로 구현을 함, 이 ListAdapter의 경우 DiffUtil을 활용하여 리스트를 업데이트 할 수 있는 기능을 추가한 Adapter로 볼 수 있음

  • 그리고 외부에서 아이템 리스트를 교체하고 특정 포지션의 아이템 반환하는 처리를 기존 Adapter를 쓴다면 손수 구현을 해야하지만 ListAdapter를 활용해서 이 부분도 간소화 할 수 있었음, 이 부분이 중요한 것도 리스트 데이터 교체나 현재 리스트 인덱스를 가져오는 등의 처리를 편히 쓸 수 있는 부분도 있음

  • DiffUtil이 무조건 장점이 될 수 있다고 볼 수 없는데 이 부분을 ListAdapter를 활용해서 리스트 데이터 교체를 좀 더 극대화시킴, 그리고 인덱스를 가져오는 것 역시 선택한 아이템에 대한 세부내용을 가져와서 보여줘야 하기 때문에 이 부분 역시도 메소드로 활용하기 쉽기 때문에 적용함

public class HomeAdapter extends ListAdapter<NewsListResponseDto, HomeAdapter.NewsListViewHolder> {

    private OnItemClickListener listener;

    protected HomeAdapter(@NonNull DiffUtil.ItemCallback<NewsListResponseDto> diffCallback) {
        super(diffCallback);
    }

    @NonNull
    @Override
    public NewsListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        ListItemNewsBinding binding = ListItemNewsBinding.inflate(inflater, parent, false);
        return new NewsListViewHolder(binding);
    }

    @Override
    public void onBindViewHolder(@NonNull NewsListViewHolder holder, int position) {
        final NewsListResponseDto newsListResponseDto = this.getItem(position);
        holder.itemView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                listener.onItemClick(newsListResponseDto);
            }
        });
        holder.bind(newsListResponseDto);
    }

    static class NewsListViewHolder extends RecyclerView.ViewHolder {
        private final ListItemNewsBinding binding;

        public NewsListViewHolder(@NonNull ListItemNewsBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }

        public final void bind(@NotNull NewsListResponseDto newsListResponseDto) {
            binding.setProperty(newsListResponseDto);
            binding.executePendingBindings();
        }
    }

    public interface OnItemClickListener {
        void onItemClick(NewsListResponseDto newsListResponseDto);
    }

    public void setOnItemClickListener(OnItemClickListener listener) {
        this.listener = listener;
    }

}
  • 위와 같이 데이터와 ViewHolder를 가지고 ListAdapter를 확장하여 Adapter를 만듬

  • ViewHolder에서도 그리고 Adapter 내부 자체도 Data Binding을 적용했기 때문에 View를 쉽게 부르고 Data 연결 처리도 편리하게 처리했음

  • 하나 더 적용한 부분에 있어서는 클릭 이벤트 처리임, 이제 RecyclerView에서 아이템을 클릭하면 해당 아이템에 대한 세부내용에 대해서 서버를 통해서 불러와서 보여줄 것이기 때문에 클릭 리스너도 만들어서 itemView에 적용함

  • 여기서 ListAdapter의 경우 리스트 항목 변경에 있어서 이 RecyclerView를 구현한 UI에서 적용을 하는데 이 부분에 있어서는 UI는 철저하게 UI 변경과 관련한 처리만 시키기 위해서 BindingAdapter를 적용시킴

Binding Adapter

  • 앞서 지속적으로 데이터 처리와 설정에 있어서 굳이 Fragment나 Activity 단에서 위임하지 않고 ViewModel에서 데이터를 받게 된다면 이를 바로 xml상에서 연결해서 처리할 수 있게 Data Binding의 장점을 계속 활용하고 있었음

  • 위에서 RecyclerView에서 app:listData 부분이 있었는데 이 부분은 직접 RecyclerView에 데이터 갱신과 처리를 바로 ViewModel에서 받은 data를 연결시킬 수 있도록 처리하기 위해서 별도의 BindingAdapter를 만들어서 처리함

  • 이 부분에 있어서 별도의 복잡한 로직이 있는것이 아닌 원래라면 Fragment나 Activity 같은 UI 단에서 현재 MVVM 패턴을 적용했기 때문에 실질적인 데이터 처리는 ViewModel에서 처리하고 불러와 처리하면 되지만 이 부분 역시 Data Binding의 이점을 살려서 BindingAdapter를 적용한 것

  • 그래서 이 말은 원래라면 View에서 처리할 부분에 대해서 아래와 같이 RecyclerView에 특성을 그대로 살려서 데이터를 처리한 것임

@androidx.databinding.BindingAdapter("listData")
    public static void BindData(RecyclerView recyclerView, List<NewsListResponseDto> news) {
        RecyclerView.Adapter adapter = recyclerView.getAdapter();
        HomeAdapter adapters = (HomeAdapter) adapter;
        adapters.submitList(news);
    }
  • 이렇게 된다면 흐름 자체가 ViewModel에서 서버와 통신 필요한 데이터를 받아옴 -> Data Binding으로 설정한 ViewModel의 LiveData가 BindingAdapter로 만든 위의 메소드를 통해서 해당 데이터가 갱신이 반영이 됨 -> Adapter에 있는 내용대로 list item에 응답값이 적용되어 처리됨

  • 이렇게 하면 아래와 같이 실제 Fragment에선 그냥 Adapter 인스턴스를 만들고 ViewModel을 만들기만 하면 되기 때문에 상당히 간소화 될 수 있음

binding = DataBindingUtil.inflate(inflater, R.layout.fragment_home, container, false);
        viewModel = new ViewModelProvider(this).get(HomeViewModel.class);
        binding.setLifecycleOwner(this);
        binding.setViewModel(viewModel);

        adapter = new HomeAdapter(new HomeDiffUtil());
        binding.rvNewsList.setAdapter(adapter);
  • 위와 같이 RecyclerView를 만들고 설정함에 있어서 기존의 쓰는 기술스택을 적극 활용하여서 간소화해서 이득을 볼 부분에 대해서 많이 적용을 해서 처리함

  • 앞서 RecyclerView에서 아이템 클릭시 세부화면으로 넘어간다고 하였는데, 앞서 MVVM으로 개선을 하면서 Fragment로 개선을 하면서 Intent 처리가 아닌 Navigation을 통해서 편하게 처리함

  • 이 부분을 앞서는 Navigation의 특성만을 활용했지만 현재 부분에서는 선택한 데이터에 대해서 해당 데이터 값을 가지고 세부화면에서 API 통신을 하여야 하기 때문에 argument 설정을 해서 데이터 처리를 할 수 있음

  • 앞서 로그인 & 회원가입 때와 같이 Navigation 설정을 하고 먼저 argument로 넘기게 함

  • 이 부분에 있어서 Navigation.xml에서 Fragment를 추가해서 아래와 같이 Navigation action 처리와 함께 argument를 추가해서 데이터를 넘길 수 있음

<?xml version="1.0" encoding="utf-8"?>
<navigation 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/nav_home.xml"
    app:startDestination="@id/homeFragment">

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.fitin_v2.ui.home.HomeFragment"
        android:label="fragment_home"
        tools:layout="@layout/fragment_home" >
        <action
            android:id="@+id/action_homeFragment_to_detailFragment"
            app:destination="@id/detailFragment"
            app:popUpTo="@id/homeFragment" />
    </fragment>
    <fragment
        android:id="@+id/detailFragment"
        android:name="com.example.fitin_v2.ui.detail.DetailFragment"
        android:label="DetailFragment"
        tools:layout="@layout/fragment_detail">
        <argument
            android:name="selectedNews"
            app:argType="com.example.fitin_v2.model.NewsListResponseDto"/>
    </fragment>
</navigation>
  • 그런 다음 앞서 Adapter에서 Item Click 리스너 처리를 하였는데 이 떄 Fragment에서 선택한 아이템에 대해서 아래와 같이 ViewModel에서 선택한 값에 대해서 LiveData를 설정함
adapter = new HomeAdapter(new HomeDiffUtil());
        binding.rvNewsList.setAdapter(adapter);
        adapter.setOnItemClickListener(new HomeAdapter.OnItemClickListener() {
            @Override
            public void onItemClick(NewsListResponseDto newsListResponseDto) {
                viewModel.displayNews(newsListResponseDto);
            }
        });
  • 그러면 아래와 같이 ViewModel에서 LiveData 설정으로 RecyclerView에서 선택한 Item에 대한 Dto를 받아서 설정할 수 있음
	private NewsRepository newsRepository;
    private final CompositeDisposable disposable = new CompositeDisposable();
    private final LiveData<List<NewsListResponseDto>> news;
    private MutableLiveData<NewsListResponseDto> _selectNews = new MutableLiveData<>();
    LiveData<NewsListResponseDto> selectNews;
    
    public void displayNews(NewsListResponseDto newsListResponseDto) {
        _selectNews.setValue(newsListResponseDto);
    }

    public void displayNewsFinish() {
        _selectNews.setValue(null);
    }
    
  • 이 부분에서 이렇게 할 수 있는 이유는 앞서 Fragment Navigation의 요소를 활용하였기 때문에 별도의 Intent로 데이터를 넘기는 등의 처리를 하지 않아도 자연스럽게 처리할 수 있음

  • 그 다음 Fragment에서 선택한 Item에 대해서 선택이 됐다면 세부화면으로 넘어가게 Observe 패턴 LiveData의 장점을 활용할 수 있음

viewModel.getSelectNews().observe(getViewLifecycleOwner(), selectNews -> {
            if (selectNews != null) {
                NavHostFragment.findNavController(HomeFragment.this).navigate(HomeFragmentDirections.actionHomeFragmentToDetailFragment(selectNews));
                viewModel.displayNewsFinish();
            }
        });
  • 이렇게 argument로 넘겨받아서 값을 처리할 수 있기 때문에 이 값에 대해서 ViewModel을 만들기 전 ViewModelFactory를 통해서 선택한 Dto를 만들게 아래와 같이 ViewModel에 대해서 만드는 것에 대해서 정의할 수 있음
public class DetailViewModelFactory implements ViewModelProvider.Factory {

    private final NewsListResponseDto newsListResponseDto;
    private final Application application;

    public DetailViewModelFactory(NewsListResponseDto newsListResponseDto, Application application) {
        this.newsListResponseDto = newsListResponseDto;
        this.application = application;
    }

    @NonNull
    @Override
    public <T extends ViewModel> T create(@NonNull Class<T> aClass) {
        if (aClass.isAssignableFrom(DetailViewModel.class)) {
            return (T) new DetailViewModel(this.newsListResponseDto, this.application);
        }
        throw new IllegalArgumentException("Unknown Viewmodel Class: " + aClass.getName());
    }
}
  • 그러면 ViewModel에서도 역시 argument로 받은 값을 처리해주기 위해서 아래와 같이 ViewModel을 만들 수 있음, 이렇게 받은 값은 세부화면에 맞게 관련된 내용을 API를 통해서 받아 처리하기 위해서 아래와 같이 생성자에서 받아서 처리할 수 있음
public class DetailViewModel extends AndroidViewModel {

    private NewsRepository newsRepository;
    private final CompositeDisposable disposable = new CompositeDisposable();
    private final MutableLiveData<NewsListResponseDto> _selectNews = new MutableLiveData<>();
    LiveData<NewsListResponseDto> news;

    private final LiveData<NewsResponseDto> newsDetail;

    public DetailViewModel(NewsListResponseDto newsListResponseDto, @NonNull Application application) {
        super(application);

        newsRepository = new NewsRepository(application);
        _selectNews.setValue(newsListResponseDto);
        news = _selectNews;

        Long newsId = news.getValue().getId();
        newsDetail = newsRepository.getNewsDetail(newsId);
    }

    public LiveData<NewsResponseDto> getNewsDetail() {
        return newsDetail;
    }

    @Override
    protected void onCleared() {
        super.onCleared();

        disposable.clear();
    }
}
  • 그러면 이전 MVVM과 같이 Data Binding을 통해서 ViewModel에서 정의한 LiveData를 통해서 해당 값을 연결할 수 있음
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    >

    <data>
        <variable
            name="viewModel"
            type="com.example.fitin_v2.ui.detail.DetailViewModel" />
    </data>

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.detail.DetailFragment">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="16dp">

            <ImageView
                android:id="@+id/iv_news_img"
                android:layout_width="0dp"
                android:layout_height="265dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:imageUrl="@{viewModel.newsDetail.image_url}"
                />

            <TextView
                android:id="@+id/tv_news_press"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textSize="16sp"
                android:textStyle="bold"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/iv_news_img"
                android:text="@{viewModel.newsDetail.press}"
                />

            <TextView
                android:id="@+id/tv_news_title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:textSize="18sp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_news_press"
                android:text="@{viewModel.newsDetail.title}"
                />

            <TextView
                android:id="@+id/tv_news_content"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:textSize="20sp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_news_title"
                android:text="@{viewModel.newsDetail.content}"
                />


        </androidx.constraintlayout.widget.ConstraintLayout>

    </ScrollView>

</layout>
  • navigation을 통해서 처리하였기 때문에 이제 앞서 ViewModelFactory와 ViewModel을 만들고 Fragment에서 Args를 선택한 Data를 가져와서 ViewModel을 만들어서 처리할 수 있음
public class DetailFragment extends Fragment {

    private DetailViewModel viewModel;
    private FragmentDetailBinding binding;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_detail, container, false);

        final NewsListResponseDto newsList = DetailFragmentArgs.fromBundle(getArguments()).getSelectedNews();
        final DetailViewModelFactory viewModelFactory = new DetailViewModelFactory(newsList, requireActivity().getApplication());

        viewModel = new ViewModelProvider(this, viewModelFactory).get(DetailViewModel.class);
        binding.setLifecycleOwner(this);
        binding.setViewModel(viewModel);

        return binding.getRoot();
    }
}
  • 그럼 이제 앞서 HomeFragment에서 argument로 넘긴 값을 DetailFragment에서 받게끔 위와 같이 처리해서 설정할 수 있음

BindingAdapter

  • 앞서 RecyclerView에서 data 갱신에 대해서 BindingAdapter를 설정해서 직접 Data Binding과 연관하여 처리한 부분이 있음

  • 이때 ImageView에 대해서도 Glide를 통해서 imgUrl을 처리할 것인데 Glide를 사용한 것은 이미지 자체가 단순하게 로컬에서 가져오는 것이 아닌 네트워크에서 가져와 처리하는 것인데 이 부분에 대해서 여러가지 고려사항이 있기 때문에 Glide 라이브러리를 활용함

  • 어쨌든 Glide로 가져오고 그에 대해서 Context를 가져오는 등 해당 ImageView에 있는 곳에 대해서 직접 이 네트워크 통신으로 받은 이미지 Url 처리를 해줘야하는데 이 부분에 대해서 BindingAdapter를 활용 굳이 DetailFragment나 ViewModel에서 이 이미지를 로드하고 보여주는 로직을 쓰지 않음

  • 그렇게 해서 바로 Data Binding을 활용해 XML 상에서 바로 연결해서 처리를 함

    @androidx.databinding.BindingAdapter("imageUrl")
    public static void bindImage(ImageView imageView, String imgUrl) {
        Glide.with(imageView.getContext())
                .load(imgUrl)
                .into(imageView);
    }
profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글