본문 바로가기

FLUTTER

[FLUTTER] 스크롤 시 이미지 크기 상호작용하는 UI 만들기

UI 레퍼런스를 찾아보던 중 구현해 보고 싶은 UI를 발견했다.

 

기본 구조는 상단에 이미지가 있고 그 밑으로 텍스트가 길게 늘어져 있는 아주 흔한 형태에,

 

아래로 스크롤 해 텍스트가 올라오면 이미지가 확대됨과 동시에 가려지는 UI이다.

 

 

 

먼저 기본적인 UI를 잡아보았다.

 

Scaffoldbody는 스크롤링을 위해 SingleChildScrollView 위젯을 사용하고 Column 위젯을 통해 Image, Text 위젯을 차례대로 놓았다.

 

이미지는 원하는 사이즈로 무작위 이미지를 불러올 수 있는 사이트를 이용했다. (그래서 중간중간 스샷의 이미지가 계속 바뀔 예정)

 

https://picsum.photos/

 

깔끔한 스샷을 위해 ContentWidget으로 이미지와 텍스트를 분리했다.

 

첫 번째로 화면을 스크롤하면 텍스트가 이미지 위로 올라와 이미지를 가려지게 해보자.

 

bodySingleChildScrollView 위젯을 Stack 위젯으로 감싸 층(레이어)을 만들어야 한다.

 

기본적으로 Stack 위젯의 자식 위젯들은 위에서 아래로 내려가면서 차례대로 쌓이는 방식으로 코드상으로 맨 아래에 있는 자식 위젯이 가장 위에 보이게 된다.

 

첫 번째 자식인 빨간 컨테이너는 화면의 제일 아래, 그 다음 주황색 컨테이너, 마지막으로 녹색 컨테이너 순으로 쌓인다.

 

그렇다면 텍스트가 이미지를 가려야하기 때문에 Image 위젯이 첫 번째 자식, Text 위젯을 두 번째 자식으로 놓아야 한다.

 

다만 텍스트가 담겨있는 위젯만 스크롤링이 되어야 하기 때문에 Image 위젯은 SingleChildScrollView에서 빼고 Text 위젯만 자식 위젯으로 담겨있어야 한다.

 

기존 ContentWidget에서 이미지와 텍스트를 한 번 더 분리했다.

 

의도대로 이미지 위로 텍스트가 올라왔다. (_TextWidget의 배경색을 지정하지 않으면 투명하므로 스샷처럼 뒤가 비친다. Container 위젯으로 감싸 배경색을 흰색으로 설정하여 뒤가 비치지 않도록 해야한다.)

 

 

 

 

그런데 이미지와 텍스트 모두 화면의 최상단에서 그려져 겹쳐져 있다.

 

_TextWidget의 시작점을 이미지의 하단으로 두어야 하는데, 나는 대부분의 UI를 만들때 이미지가 지정된 사이즈로 들어오지 않는다고 가정하고 만들기 때문에 하드코딩으로 이미지의 높이만큼 패딩을 줄 수 없었다. 위젯에 key를 할당해 렌더링 후 사이즈를 구해오는 방법 등, 여러가지 방법이 있지만 나는 편리한 약간의 꼼수를 사용했다.🤣

 

텍스트 위에 Image 위젯을 중복되게 두고 색상을 투명하게 주어 어떤 사이즈의 이미지가 들어와도 해당 이미지의 높이만큼 패딩이 있는것처럼 보이게 했다.

 

이미지를 중복해서 또 불러온다고? 최적화를 이따위로! 라고 하신다면 이번 한 번만 봐주세요...

 

 

 

이제 레이아웃은 완성되었고 스크롤링 시 이미지가 확대/축소 되도록 하기만 하면 된다!

 

먼저 스크롤 컨트롤러를 선언하고 SingleChildScrollView 위젯의 controller에 할당하고, initState 메서드 내에서 스크롤 컨트롤러에 리스너를 달아서 스크롤 할 때의 위치값을 가져와야 한다.

 

스크롤이 최상단일땐 pixels 값이 0.0이고 아래로 내려갈 수록 값이 증가한다.

 

이제 이 값을 활용해서 스크롤의 pixels 값이 높아질 수록 이미지를 확대시킬 것이다.

 

이미지를 확대하기 위해서 _scale 변수를 추가로 할당하고 초기값은 1.0으로 할당 후

 

Image 위젯을 Transform.scale 위젯으로 감싸 scale에 _scale 변수를 할당한다.

 

_scale 값이 1.0일 때(좌), 4.0일 때(우)

 

여러 시행착오 끝에 pixels 값으로 이미지를 확대/축소 시키는 함수를 만들어 스크롤 컨트롤러의 리스너 메서드 안에 넣어 스크롤링할 때 이미지가 확대/축소 되도록 했다.

 

 

 

 

아이폰의 경우 스크롤이 최상단에 위치해도 손가락을 아래로 쓸면 화면을 더 내릴수 있는데(플러터에선 BouncingScrollPhysics라 부르며, 안드로이드(갤럭시 등) 폰에서도 똑같이 동작하도록 하려면 SingleChildScrollView 위젯의 physics에 해당 스크롤 피직스를 할당해야 한다.) 이 경우에도 이미지가 확대되도록 했다.

 

이번 UI 만들면서 겪었던 시행착오들

1. 아이폰의 스크롤 물리? 때문에 화면 최상단에서 더 위로 스크롤 시 pixels의 값이 - 음수가 되는데, 이때 이미지가 의도한 최소값 1.0을 벗어나서 엄청 작아지거나, 이미지가 뒤집히는 현상.

  👊  계산식의 숫자와 범위들을 다양하게 넣어보면서 나름 최적의 값을 찾아 해결. 

 

2. 위와 비슷한 문제로 화면 최하단에서 더 아래로 스크롤 시 뒤에 숨어있던 확대된 이미지가 보이는 현상.

  👊  스크롤 최하단 여부 값 변수를 선언하고, 스크롤이 최하단인지 여부를 리턴해주는 메서드를 사용하여 최하단 일땐 physics에 ClampingScrollPhysics 할당하여 갤럭시처럼 화면이 더 스크롤 되는것을 방지. 최하단이 아닐땐 BouncingScrollPhysics 할당하여 해결. 

 

3. 스크롤이 내려가 이미지가 확대된 채로 뒤로가기하여 페이지를 벗어나면 확대된 이미지가 화면을 뒤덮었다가 사라지는 현상.

  👊  Transform.scale 위젯을 Container 위젯으로 한 번 더 감싼 후 clipBehavior 값을 사용하여 해결. 

 

4. 이미지의 세로 길이가 너무 길어 첫 화면에서 텍스트가 보이지 않아 하단에 텍스트가 있는지 모를 수 있는 경우.

  👊  이미지의 최대 높이를 디바이스의 높이 사이즈의 일정 비율을 넘지 않게 BoxConstraints를 활용하여 이미지와 텍스트가 적절하게 보이도록 제한. 

 

 

사실 별거 아닌 UI지만 생각보다 시행착오가 있었고, 대부분 혼자 힘으로 해결할 수 있어 좋았다. 이 밖에 텍스트가 엄청 짧아 스크롤이 필요없는 경우. 갤럭시 폴드처럼 화면 너비가 굉장히 넓은 폰의 경우 등 디테일한 작업들은 더 개선하여 실무에도 적용했다. (무엇보다 작업 내용을 글로 순서대로 적고 코드, 스샷, 영상을 따 블로그에 올리는게 제일 힘들다.. 🤣🤣🤣)

 

 

완성본

 

final _scrollCtrl = ScrollController();
double _scale = 1.0;
bool _isMaxScroll = false;

@override
void initState() {
  super.initState();
  _scrollCtrl.addListener(() {
    setImageScale(_scrollCtrl.position.pixels);
  });
}

void setImageScale(double offset) {
  double calc = offset * 0.01;
  setState(() {
    _scale = calc;
    if (calc >= 0.0 && calc < 1.0) {
      _scale = 1.0;
    } else if (calc > 10.0) {
      _scale = 10.0;
    } else if (calc < 0 || calc.isNegative) {
      _scale = (calc * -0.8) + 1.0;
    }
    if (offset >= _scrollCtrl.position.maxScrollExtent) {
      _isMaxScroll = true;
    } else {
      _isMaxScroll = false;
    }
  });
}

 

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.white,
    appBar: AppBar(
      backgroundColor: Colors.blueGrey,
      title: const Text(
        "flutter custom ui",
        style: TextStyle(
          color: Colors.white,
        ),
      ),
    ),
    body: Stack(
      children: [
        Transform.scale(
          scale: _scale,
          child: _ImageWidget(),
        ),
        SingleChildScrollView(
          controller: _scrollCtrl,
          physics: _isMaxScroll ? const ClampingScrollPhysics() : const BouncingScrollPhysics(),
          child: Column(
            children: [
              Image.network(
                "https://picsum.photos/1000/800",
                color: Colors.transparent,
              ),
              _TextWidget(),
            ],
          ),
        ),
      ],
    ),
  );
}