앞서 지금까지 회원가입 & 로그인에 대해서 진행을 하여 서버와 정상적으로 통신을 하고 구조개선까지 이루어졌음
앞으로 개발하는 프로젝트에 있어서 서버에서 주제 컨셉상 API 통신을 통해서 데이터를 받아와서 처리할 것인데, 여러 데이터를 사용자에게 보여주고 즐겨찾기와 같은 기능을 담아서 처리할 것임
그리고 그 내용에 대해서 리스트 형태로 다양한 데이터를 활용해서 사용자에게 제공할 것인데 이 보여주는 View에 있어서 RecyclerView를 활용함, 그리고 사실상 주로 많이 쓰이게 될 View 중에 하나로써 이 부분에 대해서 API 데이터를 받아와서 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>
본격적으로 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에 적용함
앞서 지속적으로 데이터 처리와 설정에 있어서 굳이 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에서 아이템 클릭시 세부화면으로 넘어간다고 하였는데, 앞서 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 = new HomeAdapter(new HomeDiffUtil());
binding.rvNewsList.setAdapter(adapter);
adapter.setOnItemClickListener(new HomeAdapter.OnItemClickListener() {
@Override
public void onItemClick(NewsListResponseDto newsListResponseDto) {
viewModel.displayNews(newsListResponseDto);
}
});
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();
}
});
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());
}
}
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();
}
}
<?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>
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();
}
}
앞서 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);
}