[flutter] - Custom Slide Panel


이미지

TodoCare 앱을 만들려고 하다보니 좌우에서 Slide Panel이 나올 필요가 있었다.

하지만 내가 찾아보기로 Plugin이 없어 내가 직접 만들어 배포하기로 하였다.

TransformAnimationController를 활용하여 개발하였다.

1. 화면 설계


먼저 Panel의 Width와 Height 그리고 손잡이 Handler Width를 직접 지정해서 사용하게 해주고 싶었다.

SlidePanel(
	slideHandlerWidth : 20,
	slidePanelWidth: 300,
  slidePanelHeight: 300,
)

그리고 Body와 Left, Right를 구분하여 내용물을 집어넣게 하였다.

SlidePanel(
	slideHandlerWidth : 20,
	slidePanelWidth: 300,
  slidePanelHeight: 300,
	body: body,
	leftSlide: ...,
	rightSlide: ...,
)

Left와 Right는 동일하니 Left를 기준으로 설명하겠다.

LeftSlidePanel에는 내용물은 main에서 받아온 body를 넘겨주고 slidePanel의 사이즈를 넣어준다.

LeftSlidePanel(
	body: Container(
		width: widget.slidePanelWidth + widget.slideHandlerWidth,
		height: widget.slidePanelHeight,
		color: Colors.red, //Test용 색 
		child: widget.leftSlide),
	slideHandlerWidth: widget.slideHandlerWidth,
	slidePanelHeight: widget.slidePanelHeight,
	slidePanelWidth: widget.slidePanelWidth,
)

여기서 bodyContainer로 감싸준 이유는 SlidePanel의 사이즈만큼의 공간에 내용물을 넣기 위함이다.

그리고 Positioned을 활용해 SlidePanel을 화면에서 숨겨주자.

Positioned(
	top: (size.height - widget.slidePanelHeight) / 2, // 화면 정중앙에 놓기
	left: -widget.slidePanelWidth, // Handler를 제외한 너비만큼 숨기기
	child: widget.body, // 내용물
)

2. 애니메이션


자 이제 TransformAnimationController를 활용하여 슬라이드 효과를 줘보자.

AnimatedBuilder(
      animation: _animationController,
      builder: (context, _) {
        final leftSlide = widget.slidePanelWidth * _animationController.value;
				// 애니메이션 값(0.0 ~ 1.0) 만큼 Width값 이동  
        return Positioned(
          top: (size.height - widget.slidePanelHeight) / 2,
          left: -widget.slidePanelWidth,
          child: Transform(
              alignment: Alignment.centerLeft,
              transform: Matrix4.identity()..translate(leftSlide), // 화면 이동
              child: widget.body ),
        );
      },
    )

드래그에 따라서 화면이 이동해야하니 GestureDetector를 활용하여 Drag를 해보자.

class _LeftSlidePanelState extends State<LeftSlidePanel>
    with SingleTickerProviderStateMixin {
  static const Duration toggleDuration = Duration(milliseconds: 250); // 애니메이션 시간
  AnimationController _animationController; // 애니메이션 컨트롤러
  bool _canBeDragged = false; // Drag 가능 여부
  @override
  void initState() {
    super.initState();
    _animationController =
        AnimationController(vsync: this, duration: toggleDuration);
  }

  void close() {
    _animationController.reverse();
  }

  void open() {
    _animationController.forward();
  }

  // Drag가능 범위 (Handler)인지 확인 후 Drag 가능 여부 판단
  void _onDragStart(DragStartDetails details) {
    bool isDragOpenFromLeft = _animationController.isDismissed &&
        details.globalPosition.dx < widget.slideHandlerWidth;
    bool isDragCloseFromRight = _animationController.isCompleted &&
        details.globalPosition.dx >
            (widget.slidePanelWidth - widget.slideHandlerWidth);
    _canBeDragged = isDragOpenFromLeft || isDragCloseFromRight;
  }

  // Drag 가능 하면 애니메이션 컨트롤러 값 증가
  void _onDragUpdate(DragUpdateDetails details) {
    if (_canBeDragged) {
      double delta = details.primaryDelta / widget.slidePanelWidth;
      _animationController.value += delta;
    }
  }

	// Drag 끝나면 분기에 따라 처리
  void _onDragEnd(DragEndDetails details) {
    
		// 이미 애니메이션이 끝나거나 취소 됬으면 바로 종료
		if (_animationController.isDismissed || _animationController.isCompleted) {
			return ;
    }

		// 특정 속도 이상으로 드래그 할 경우
    if (details.velocity.pixelsPerSecond.dx.abs() >= 365.0) {
      double visualVelocity = details.velocity.pixelsPerSecond.dx /
          MediaQuery.of(context).size.width;

			//  애니메이션 Fling
      _animationController.fling(velocity: visualVelocity);
    } else if (_animationController.value < 0.5) {
			// 절반 이상 못오면 애니메이션 reverse
      close();
    } else {
			// 절반 이상 진행 했으면 애니메이션 forward
      open();
    }
  }

  @override
  Widget build(BuildContext context) {
    var size = MediaQuery.of(context).size;

    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, _) {
        final leftSlide = widget.slidePanelWidth * _animationController.value;

        return Positioned(
          top: (size.height - widget.slidePanelHeight) / 2,
          left: -widget.slidePanelWidth,
          child: Transform(
              alignment: Alignment.centerLeft,
              transform: Matrix4.identity()..translate(leftSlide),
              child: GestureDetector(
                      onHorizontalDragStart: _onDragStart,
                      onHorizontalDragUpdate: _onDragUpdate,
                      onHorizontalDragEnd: _onDragEnd,
                      child: widget.body,
                    )
							),
        );
      },
    );
  }
}

이제 Drag가 정상적으로 동작한다.

그런데 문제가 있다.

오른쪽과 왼쪽 Slide Panel이 한쪽이 나와있으면 다른쪽이 못나오거나 들어가줘야한다.

이부분을 Provider를 활용해 해결해봤다.

class IsOpenedProvider with ChangeNotifier {
  bool isLeftOpened = false;  // 왼쪽 슬라이드 패널 오픈 여부
  bool isRightOpened = false; // 오른쪽 슬라이드 패널 오픈 여부

  bool getIsLeftOpened() => isLeftOpened;
  bool getIsRightOpened() => isRightOpened;

  void setOpenLeftState(bool state) {
    isLeftOpened = state;
    notifyListeners();
  }

  void setOpenRightState(bool state) {
    isRightOpened = state;
    notifyListeners();
  }
}

그리고 패널별 동작을 추가해주자

먼저 왼쪽 패널에서는

void _onDragEnd(DragEndDetails details, IsOpenedProvider isOpenedProvider) {
    if (_animationController.isDismissed) {
			// 애니메이션 취소된 상태이면 왼쪽 패널 상태를 열리지 않음으로 변경
      isOpenedProvider.setOpenLeftState(false);
      return;
    }

    if (_animationController.isCompleted) {
			// 애니메이션 성공이면 왼쪽 패널 다 열린 상태로 변경
      isOpenedProvider.setOpenLeftState(true);
      return;
    }
    if (details.velocity.pixelsPerSecond.dx.abs() >= 365.0) {
      double visualVelocity = details.velocity.pixelsPerSecond.dx /
          MediaQuery.of(context).size.width;

			//여는 속도가 +면 오른쪽 이동중(열리는 중) 열린거로 오픈
      if (visualVelocity > 0) {
        isOpenedProvider.setOpenLeftState(true);
      } else {
				// 여는 속도가 -면 왼쪽으로 이동중(닫히는 중) 닫힌거로 설정 
        isOpenedProvider.setOpenLeftState(false);
      }

      _animationController.fling(velocity: visualVelocity);
    } else if (_animationController.value < 0.5) {
			//열거나 닫을때 맞춰서 상태 설정
      isOpenedProvider.setOpenLeftState(false);
      close();
    } else {
      isOpenedProvider.setOpenLeftState(true);
      open();
    }
  }

@override
  Widget build(BuildContext context) {
		// 프로바이더 가져오기
    IsOpenedProvider isOpenedProvider = Provider.of<IsOpenedProvider>(context);
    var size = MediaQuery.of(context).size;

		// 현재 패널의 상태가 False(안열림)이지만 열려있는 상태 (애니메이션 완료)면 애니메이션 역재생
    if (isOpenedProvider.getIsLeftOpened() == false &&
        _animationController.isCompleted) {
      _animationController.reverse();
    }

    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, _) {
        final leftSlide = widget.slidePanelWidth * _animationController.value;
        return Positioned(
          top: (size.height - widget.slidePanelHeight) / 2,
          left: -widget.slidePanelWidth,
          child: Transform(
              alignment: Alignment.centerLeft,
              transform: Matrix4.identity()..translate(leftSlide),
              child: isOpenedProvider.getIsRightOpened()
                  ? GestureDetector(
                      onTap: () {
                        isOpenedProvider.setOpenRightState(false);
                      },
                      child: widget.body)
                  : GestureDetector(
                      onHorizontalDragStart: _onDragStart,
                      onHorizontalDragUpdate: _onDragUpdate,
                      onHorizontalDragEnd: (details) =>
                          _onDragEnd(details, isOpenedProvider),
                      child: widget.body,
                    )),
        );
      },
    );
  }

오른쪽 패널도 다음과 같이 설정하자.

void _onDragEnd(DragEndDetails details, IsOpenedProvider isOpenedProvider) {
    if (_animationController.isDismissed) {
      isOpenedProvider.setOpenRightState(false);
      return;
    }

    if (_animationController.isCompleted) {
      isOpenedProvider.setOpenRightState(true);
      return;
    }

    if (details.velocity.pixelsPerSecond.dx.abs() >= 365.0) {
      double visualVelocity = details.velocity.pixelsPerSecond.dx /
          MediaQuery.of(context).size.width;

			//여는 속도가 +면 오른쪽 이동중(닫히는 중) 닫힌거로 설정
      if (visualVelocity > 0) {
        isOpenedProvider.setOpenRightState(false);
      } else {
				//여는 속도가 -면 왼쪽 이동중(열리는 중) 열린거로 설정
        isOpenedProvider.setOpenRightState(true);
      }

      _animationController.fling(velocity: -visualVelocity);
    } else if (_animationController.value < 0.5) {
      isOpenedProvider.setOpenRightState(false);
      close();
    } else {
      isOpenedProvider.setOpenRightState(true);
      open();
    }
  }

  @override
  Widget build(BuildContext context) {
    var size = MediaQuery.of(context).size;
    IsOpenedProvider isOpenedProvider = Provider.of<IsOpenedProvider>(context);

    if (isOpenedProvider.getIsRightOpened() == false &&
        _animationController.isCompleted) {
      _animationController.reverse();
    }

    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, _) {
        final rightSlide = -widget.slidePanelWidth * _animationController.value;
        return Positioned(
          top: (size.height - widget.slidePanelHeight) / 2,
          left: size.width - widget.slideHandlerWidth,
          child: Transform(
              alignment: Alignment.centerLeft,
              transform: Matrix4.identity()..translate(rightSlide),
              child: isOpenedProvider.getIsLeftOpened() == true
                  ? GestureDetector(
                      onTap: () {
                        isOpenedProvider.setOpenLeftState(false);
                      },
                      child: widget.body)
                  : GestureDetector(
                      onHorizontalDragStart: (details) =>
                          _onDragStart(details, size),
                      onHorizontalDragUpdate: _onDragUpdate,
                      onHorizontalDragEnd: (details) =>
                          _onDragEnd(details, isOpenedProvider),
                      child: widget.body,
                    )),
        );
      },
    );
  }

이제 정상적으로 Drag할 경우 동작한다.

3. 추가 옵션


여기서 추가로 옵션으로 설정한게

  1. 메인 화면 터치 시 슬라이드 닫기
  2. 좌우 슬라이드 on/off

먼저 메인 화면 터치 시 슬라이드 닫기부터 변수를 내려서 설정해주었다.

SlidePanel(
	...
	slideOffBodyTap: true,
	...
)

BodyPanel에서 설정해주자.

slideOffBodyTaptrue인 경우에는 GestureDector를 통해 Tap에 slide 닫기 기능을 연결해주자.

Provider의 값을 변경하면 화면이 rebuild되면서 그 값에 따라 슬라이드가 동작한다.

class _BodyPanelState extends State<BodyPanel> {
  @override
  Widget build(BuildContext context) {
    IsOpenedProvider isOpenedProvider = Provider.of<IsOpenedProvider>(context);
    if (widget.slideOffBodyTap) {
      return GestureDetector(
        onTap: () {
          isOpenedProvider.setOpenLeftState(false);
          isOpenedProvider.setOpenRightState(false);
        },
        child: widget.body,
      );
    }
    return widget.body;
  }
}

두번째로 좌우 슬라이드를 옵션으로 설정하게 해주자.

SlidePanel(
	...
	leftPanelVisible: true,
  rightPanelVisible: true,
	...
)

이렇게 변수를 전달하여 다음과 같이 코드를 작성하였다.

class _SlidePanelState extends State<SlidePanel> {
  bool isOpened = false;
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<IsOpenedProvider>(
      create: (_) => IsOpenedProvider(),
      child: Stack(
        children: <Widget>[
          BodyPanel(
            body: widget.body,
            slideOffBodyTap: widget.slideOffBodyTap,
          ),
					// 왼쪽 패널 Visible이 true면 그리기 아니면 빈 Container
          widget.leftPanelVisible
              ? LeftSlidePanel(
                  body: Container(
                      width: widget.slidePanelWidth + widget.slideHandlerWidth,
                      height: widget.slidePanelHeight,
                      color: Colors.red,
                      child: widget.leftSlide),
                  slideHandlerWidth: widget.slideHandlerWidth,
                  slidePanelHeight: widget.slidePanelHeight,
                  slidePanelWidth: widget.slidePanelWidth,
                )
              : Container(),
					// 오른쪽 패널 Visible이 true면 그리기 아니면 빈 Container
          widget.rightPanelVisible
              ? RightSlidePanel(
                  body: Container(
                      width: widget.slidePanelWidth + widget.slideHandlerWidth,
                      height: widget.slidePanelHeight,
                      color: Colors.green,
                      child: widget.rightSlide),
                  slideHandlerWidth: widget.slideHandlerWidth,
                  slidePanelHeight: widget.slidePanelHeight,
                  slidePanelWidth: widget.slidePanelWidth,
                )
              : Container()
        ],
      ),
    );
  }
}

github - 소스코드 보기

이제 모두 정상 작동한다.

나중에 수정할 부분은 Handler와 Panel body를 구분하여 Handler를 따로 만들어서 연결하게 수정할 예정이다.

일단 다 작성한 내용을 Plugin으로 만들계획이다.