본문 바로가기
Dart/Flutter

[Flutter] SliverPersistentHeaderDelegate AutoCalculate Height

by @김상현 2023. 6. 14.
반응형

CustomScrollView를 사용하다 보면

SliverPersistentHeader를 사용하여 뷰포트 상단에 닿으면 상단에 고정(Pin) 되는 위젯을 구현해야 할 때가 생긴다.

 

하지만, 이러한 경우 SliverPersistentHeaderDelegate에 minExtent와 maxExtent를 수동적으로 지정해줘야 한다.

물론, layout 관련 에러가 뜨지 않게끔 맞춰서 할 수 있지만 꽤나 피곤하고 이게 맞나?라는 생각을 지울 순 없었다.

 

그래서 찾아봤다. 

 

우선 Chat GPT 3에게 도움을 요청했지만 실효성 있는 결과물은 도출해 내지 못했다.

(내가 질문을 못하는 걸 지도..?)

 

그래서 결국 전통의 방식(?)인 구글 검색을 했다.

StackOverflow도 방문하고 Github도 방문하고 결국 찾아내었다.

 

https://pub.dev/packages/sliver_tools

 

sliver_tools | Flutter Package

A set of useful sliver tools that are missing from the flutter framework

pub.dev

위 라이브러리에서 SliverPinnedHeader를 사용하면 단순히 child만 넣으면 된다.

SliverPersistentHeaderDelegate와 다르게 minExtent, maxExtent를 지정하지 않아도 된다는 점이

아주 속 시원하다.

 

하지만 SliverPinnedHeader 하나 사용하자고 sliver_tools를 설치하기는 반골 성향인 나에겐 불편하게 느껴졌다

 

그래서! 코드를 뜯어보고 그대로 가져왔다. (사실 코드 길면 그냥 설치하려 했음)

 

class SliverPinnedHeader extends SingleChildRenderObjectWidget {
  const SliverPinnedHeader({
    Key? key,
    required Widget child,
  }) : super(key: key, child: child);

  @override
  RenderSliverPinnedHeader createRenderObject(BuildContext context) {
    return RenderSliverPinnedHeader();
  }
}

class RenderSliverPinnedHeader extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }
    final paintedChildExtent = min(
      childExtent,
      constraints.remainingPaintExtent - constraints.overlap,
    );
    geometry = SliverGeometry(
      paintExtent: paintedChildExtent,
      maxPaintExtent: childExtent,
      maxScrollObstructionExtent: childExtent,
      paintOrigin: constraints.overlap,
      scrollExtent: childExtent,
      layoutExtent: max(0.0, paintedChildExtent - constraints.scrollOffset),
      hasVisualOverflow: paintedChildExtent < childExtent,
    );
  }

  @override
  double childMainAxisPosition(RenderBox child) {
    return 0;
  }
}

 

위 코드를 내가 사용할 프로젝트에 옮겨 넣은 뒤 사용하면 된다.

 

추가적으로 나는 SliverPinnedHeader의 child가 상태(상단에 붙어있는지)에 따라 바뀌길 원하기 때문에 아래와 같이 조금 수정했다.

 

class SliverSwitchingPinnedHeader extends HookWidget{
  const SliverSwitchingPinnedHeader({Key? key,required this.child, this.pinnedChild}) : super(key: key);
  final Widget child;
  final Widget? pinnedChild;

  @override
  Widget build(BuildContext context){
    final state = useState(false);

    return SliverPinnedHeader(
      onChangePinnedState: (pinned){
        if(state.value != pinned){
          state.value = pinned;
        }
      },
      child: AnimatedSwitcher(
        duration: const Duration(milliseconds: 300),
        child: state.value ? pinnedChild ?? child : child,
      ),
    );
  }
}

class SliverPinnedHeader extends SingleChildRenderObjectWidget {
  final ValueChanged<bool>? onChangePinnedState;

  const SliverPinnedHeader({
    Key? key,
    required child,
    this.onChangePinnedState,
  }) : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverPinnedHeader(
      onChangePinnedState: onChangePinnedState,
    );
  }
}

class RenderSliverPinnedHeader extends RenderSliverSingleBoxAdapter {
  bool _pinnedState = false;
  final ValueChanged<bool>? onChangePinnedState;

  RenderSliverPinnedHeader({this.onChangePinnedState});

  @override
  void performLayout() {
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }
    final paintedChildExtent = min(
      childExtent,
      constraints.remainingPaintExtent - constraints.overlap,
    );

    geometry = SliverGeometry(
      paintExtent: paintedChildExtent,
      maxPaintExtent: childExtent,
      maxScrollObstructionExtent: childExtent,
      paintOrigin: constraints.overlap,
      scrollExtent: childExtent,
      layoutExtent: max(0.0, paintedChildExtent - constraints.scrollOffset),
      hasVisualOverflow: paintedChildExtent < childExtent,
    );

    SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
      bool newPinnedState =
          constraints.scrollOffset <= 0;
      newPinnedState = !newPinnedState;
      if (_pinnedState != newPinnedState) {
        _pinnedState = newPinnedState;
        onChangePinnedState?.call(_pinnedState);
      }
    });
  }

  @override
  double childMainAxisPosition(RenderBox child) {
    return 0;
  }
}

 

위 코드와 같이하면 상태(Boolean) 값에 따라 두 가지 위젯을 나타낼 수 있다.

다만 필자는 Riverpod를 사용하기 때문에 그렇지 않으신 분들이라면

HookWidget을 StatefulWidget으로 변환하여 사용하면 될 것이다.

반응형

댓글