개인 프로젝트에 드래그 가능하고 탭도 가능한 RatingBar가 필요해서 커스텀 중인데, 꽤 쉽지가 않았다. 안드로이드 View 위젯들을 사용할 때는 RatingBar가 따로 있었으나, Compose에는 유사한 기본 Composable이 제공되지 않아 커스텀해야 했다.
그래도 얼추 드래그와 탭을 모두 구현해 두고, 이를 블로그에 쓰려고 테스트해보고 있었는데 문제가 발생했다.
구조 및 증상
일단 RatingBar Composable에 rating 값과 onRatingChanged 콜백이 전달되는데, 이 rating 값은 하위 컴포넌트들에 전달되어서 적절하게 별점을 그리기 위해 사용되며, 드래그 제스처와 탭 제스처는 onRatingChanged를 트리거한다.
이를 테스트해 보기 위해서 Preview를 생성하고 가상머신에서 동작시켰는데, 의도한 대로 동작하지 않는 부분이 하나 있었다.
탭 제스처를 입력받았을 때, 인자로 받은 rating과 새롭게 부여될 newRating을 비교하게 되는데 이때 자꾸 rating 값이 초기화할 때 입력받는 값을 그대로 사용하는 것이었다.
이게 실제로 별점 UI에 변화가 생기는 것을 보면 Composable이 의도대로 recomposition 되는 것 같은데 이상하게 저 로직은 최신화된 rating 값을 이용하지 않고 있다. 혹시 몰라서 Break Point들을 걸어서 확인해 봤다.
디버깅 과정
처음 composition 될 때는 상위 Composable의 state인 rating에서 받아온 2.5를 받아 그려지는 모습이다.
모든 Composable이 실행되자 UI도 그에 맞춰서 그려졌다.
이때 별 세 개를 꽉 채우도록 탭하자, 콜백이 불리며 Float 인자에 3.0이 정상적으로 전달되고 상위 Composable에서 state를 갱신한다.
갱신 후 rating의 value에 접근해 보면 정상적으로 3.0으로 반영된 모습이다.
그다음 RatingBar가 recomposition 되기 시작하며, rating 파라미터에 3.0을 전달받았음을 확인할 수 있다.
물론 별점을 나타내는 하위 컴포넌트들도 recomposition 되고 있으며, rating 값은 여전히 전달받은 3.0이다.
모든 recomposition이 완성되자 별점 UI에 3칸이 가득 찬 모습을 볼 수 있다. 이제 내가 의도한 것은 탭 제스처를 통해 같은 별점을 주면 별점이 다시 0점인 상태가 되는 것이기 때문에 3칸이 가득 차는 부분에 다시 탭 제스처를 준다.
이때 보면 rating이 3.0이 아닌 2.5인 것을 확인할 수 있다.
상위 컴포넌트에서 state가 갱신되기 직전에 확인해 보면, rating의 value는 이전의 탭의 결과로 여전히 3.0을 유지하고 있고, 새로 콜백을 통해 입력받은 Float 값으로 3.0을 정상적으로 넘겨받은 것을 확인할 수 있다.
여기서 조금 의문을 느꼈다. 분명 값이 변경되면 recomposition이 정상적으로 실행되며 그에 맞춰 UI도 변경된다. 그런데 탭 제스처를 관리하는 내부에서는 계속해서 초기에 입력받은 rating 값으로 로직을 동작시킨다. 모종의 이유로 rating 값이 유지된다면 UI가 바뀌지도 않을 것이고, 지금과 같은 상황에서 recomposition이 다시 발생해야 하는데 발생하지 않고 종료된다.
그래서, 해당 제스처에 대한 pointerInput을 구현하는 Modifier 객체가 재활용되고 있고, 그 내부에서 사용 중인 rating 값은 처음에 인자로 받았던 rating 값을 로컬 스코프에 복사한 변수가 아닐까 하는 의심을 했다.
그래서 해당 메서드에 대한 docs를 자세히 읽었는데, key에 대한 설명과 그 존재가 눈에 들어왔다.
해결
key1 인자에 rating을 넣어줬다. 설명대로라면 저 Block은 rating이 변경됐을 때 pointerInput이 recomposition 되면서, 재시작할 것이다.
1시간을 넘게 고생한 것 같은데, 드디어 의도대로 동작하는 모습에 도파민에 범벅이 됐다.
결론
1. pointerInput modifier는 인자로 받은 콜백 내에서 사용되는 변수가 변경되더라도 recomposition이 일어나지 않는다. composition이 발생할 때 스코프 내의 Block에서 로컬 변수를 capture 해서 사용하기 때문이다.
2. 로컬 변수의 변경에 따른 Block의 재시작을 트리거하기 위해서는 인자의 key값으로 해당 변수를 부여해야 한다.
3. 다양한 Composable에는 퍼포먼스를 위한 recomposition 최적화가 많이 되어있어, 이런 경우가 많을 것으로 생각된다. 항상 유의해야겠다..