개요

FractionalTranslation은 자식을 그리기 전에 상대적인 이동 변환을 적용하는 위젯입니다.

이동은 자식의 크기에 대한 비율로 표현됩니다. 예를 들어, x가 0.25인 오프셋은 자식 너비의 1/4만큼 수평 이동을 의미합니다.

히트 테스트는 자식이 오버플로우되어 범위를 벗어나더라도 FractionalTranslation의 경계 내에서만 감지됩니다.

참조: https://api.flutter.dev/flutter/widgets/FractionalTranslation-class.html

언제 사용하나요?

  • 자식의 크기에 상대적인 위치 조정이 필요할 때
  • 반응형 애니메이션에서 크기 독립적인 이동이 필요할 때
  • 오버레이나 툴팁의 위치를 동적으로 조정할 때
  • 자식 위젯의 중앙점이나 모서리를 기준으로 이동시킬 때
  • 슬라이드 인/아웃 애니메이션을 구현할 때

기본 사용법

// 오른쪽으로 절반, 아래로 1/4 이동
FractionalTranslation({
  translation: { x: 0.5, y: 0.25 },
  child: Container({
    width: 100,
    height: 100,
    color: 'blue'
  })
})

// 중앙으로 이동 (자신의 크기의 절반만큼 왼쪽 위로)
FractionalTranslation({
  translation: { x: -0.5, y: -0.5 },
  child: Container({
    width: 50,
    height: 50,
    color: 'red'
  })
})

// 위로 완전히 이동 (숨김)
FractionalTranslation({
  translation: { x: 0, y: -1.0 },
  child: Container({
    width: 200,
    height: 80,
    color: 'green'
  })
})

Props

translation (필수)

값: { x: number, y: number }

자식의 크기에 대한 비율로 표현되는 이동 오프셋입니다.

  • x: 너비 대비 수평 이동 비율 (-무한대 ~ +무한대)
  • y: 높이 대비 수직 이동 비율 (-무한대 ~ +무한대)

값의 의미:

  • 0.0: 이동 없음
  • 1.0: 자신의 너비(x) 또는 높이(y)만큼 이동
  • -1.0: 자신의 너비(x) 또는 높이(y)만큼 반대 방향 이동
  • 0.5: 자신의 너비(x) 또는 높이(y)의 절반만큼 이동
FractionalTranslation({
  translation: { x: 0.25, y: -0.5 },  // 오른쪽으로 1/4, 위로 1/2
  child: child
})

FractionalTranslation({
  translation: { x: -1.0, y: 0 },     // 완전히 왼쪽으로 이동
  child: child
})

child

값: Widget | undefined

이동 변환이 적용될 자식 위젯입니다.

실제 사용 예제

예제 1: 슬라이드 인 애니메이션

const SlideInPanel = ({ isVisible, children }) => {
  const [isAnimating, setIsAnimating] = useState(false);
  
  useEffect(() => {
    if (isVisible) {
      setIsAnimating(true);
      setTimeout(() => setIsAnimating(false), 300);
    }
  }, [isVisible]);
  
  return AnimatedContainer({
    duration: Duration.milliseconds(300),
    curve: Curves.easeInOut,
    child: FractionalTranslation({
      translation: { 
        x: isVisible ? 0 : 1.0,  // 화면 밖에서 슬라이드 인
        y: 0 
      },
      child: Container({
        width: 300,
        padding: EdgeInsets.all(20),
        decoration: BoxDecoration({
          color: 'white',
          borderRadius: BorderRadius.only({
            topLeft: Radius.circular(16),
            topRight: Radius.circular(16)
          }),
          boxShadow: [
            BoxShadow({
              color: 'rgba(0,0,0,0.2)',
              blurRadius: 10,
              offset: { x: 0, y: -2 }
            })
          ]
        }),
        child: Column({
          mainAxisSize: MainAxisSize.min,
          children: children
        })
      })
    })
  });
};

예제 2: 커스텀 툴팁

const CustomTooltip = ({ text, position, isVisible }) => {
  // 툴팁 위치에 따른 이동 계산
  const getTranslation = () => {
    switch (position) {
      case 'top': return { x: -0.5, y: -1.1 };    // 위쪽, 중앙 정렬
      case 'bottom': return { x: -0.5, y: 0.1 };  // 아래쪽, 중앙 정렬
      case 'left': return { x: -1.1, y: -0.5 };   // 왼쪽, 세로 중앙
      case 'right': return { x: 0.1, y: -0.5 };   // 오른쪽, 세로 중앙
      default: return { x: 0, y: 0 };
    }
  };
  
  return AnimatedOpacity({
    opacity: isVisible ? 1.0 : 0.0,
    duration: Duration.milliseconds(200),
    child: FractionalTranslation({
      translation: getTranslation(),
      child: Container({
        padding: EdgeInsets.symmetric({ horizontal: 12, vertical: 8 }),
        decoration: BoxDecoration({
          color: 'rgba(0,0,0,0.8)',
          borderRadius: BorderRadius.circular(6)
        }),
        child: Text(text, {
          style: TextStyle({
            color: 'white',
            fontSize: 12
          })
        })
      })
    })
  });
};

예제 3: 오버레이 알림

const OverlayNotification = ({ message, type, onDismiss }) => {
  const [isExiting, setIsExiting] = useState(false);
  
  const handleDismiss = () => {
    setIsExiting(true);
    setTimeout(onDismiss, 300);
  };
  
  useEffect(() => {
    const timer = setTimeout(handleDismiss, 4000);
    return () => clearTimeout(timer);
  }, []);
  
  return FractionalTranslation({
    translation: { 
      x: 0, 
      y: isExiting ? -1.2 : 0  // 위로 슬라이드아웃
    },
    child: AnimatedContainer({
      duration: Duration.milliseconds(300),
      margin: EdgeInsets.all(16),
      padding: EdgeInsets.all(16),
      decoration: BoxDecoration({
        color: type === 'success' ? '#4CAF50' : '#F44336',
        borderRadius: BorderRadius.circular(8),
        boxShadow: [
          BoxShadow({
            color: 'rgba(0,0,0,0.2)',
            blurRadius: 8,
            offset: { x: 0, y: 2 }
          })
        ]
      }),
      child: Row({
        children: [
          Icon({
            icon: type === 'success' ? Icons.check : Icons.error,
            color: 'white'
          }),
          SizedBox({ width: 12 }),
          Expanded({
            child: Text(message, {
              style: TextStyle({ color: 'white' })
            })
          }),
          GestureDetector({
            onTap: handleDismiss,
            child: Icon({
              icon: Icons.close,
              color: 'white',
              size: 18
            })
          })
        ]
      })
    })
  });
};

예제 4: 이미지 갤러리 미리보기

const ImagePreview = ({ image, isExpanded, onToggle }) => {
  return GestureDetector({
    onTap: onToggle,
    child: AnimatedContainer({
      duration: Duration.milliseconds(400),
      curve: Curves.easeInOut,
      child: FractionalTranslation({
        translation: isExpanded 
          ? { x: -0.5, y: -0.5 }  // 중앙으로 이동
          : { x: 0, y: 0 },       // 원래 위치
        child: Transform.scale({
          scale: isExpanded ? 2.0 : 1.0,
          child: Container({
            width: 120,
            height: 120,
            decoration: BoxDecoration({
              borderRadius: BorderRadius.circular(8),
              boxShadow: isExpanded ? [
                BoxShadow({
                  color: 'rgba(0,0,0,0.3)',
                  blurRadius: 20,
                  offset: { x: 0, y: 10 }
                })
              ] : []
            }),
            child: ClipRRect({
              borderRadius: BorderRadius.circular(8),
              child: Image({
                src: image.url,
                objectFit: 'cover'
              })
            })
          })
        })
      })
    })
  });
};

예제 5: 플로팅 액션 메뉴

const FloatingActionMenu = ({ isOpen, actions }) => {
  return Column({
    mainAxisSize: MainAxisSize.min,
    children: [
      // 액션 버튼들 (아래에서 위로 나타남)
      ...actions.map((action, index) => 
        AnimatedContainer({
          duration: Duration.milliseconds(200 + index * 50),
          child: FractionalTranslation({
            translation: { 
              x: 0, 
              y: isOpen ? 0 : 1.5 + (index * 0.2)  // 아래로 숨김
            },
            child: Container({
              margin: EdgeInsets.only({ bottom: 16 }),
              child: Row({
                mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  // 레이블
                  AnimatedOpacity({
                    opacity: isOpen ? 1.0 : 0.0,
                    duration: Duration.milliseconds(150),
                    child: Container({
                      padding: EdgeInsets.symmetric({ 
                        horizontal: 12, 
                        vertical: 8 
                      }),
                      decoration: BoxDecoration({
                        color: 'rgba(0,0,0,0.7)',
                        borderRadius: BorderRadius.circular(4)
                      }),
                      child: Text(action.label, {
                        style: TextStyle({ 
                          color: 'white',
                          fontSize: 12
                        })
                      })
                    })
                  }),
                  SizedBox({ width: 16 }),
                  // 액션 버튼
                  FloatingActionButton({
                    mini: true,
                    onPressed: action.onPressed,
                    backgroundColor: action.color,
                    child: Icon(action.icon)
                  })
                ]
              })
            })
          })
        })
      ),
      // 메인 FAB
      FloatingActionButton({
        onPressed: () => setIsOpen(!isOpen),
        child: AnimatedRotation({
          turns: isOpen ? 0.125 : 0,  // 45도 회전
          duration: Duration.milliseconds(200),
          child: Icon(Icons.add)
        })
      })
    ]
  });
};

좌표계 이해

이동 방향

// 양수 방향
FractionalTranslation({ 
  translation: { x: 1.0, y: 1.0 },  // 오른쪽 아래로 이동
  child: child
})

// 음수 방향  
FractionalTranslation({ 
  translation: { x: -1.0, y: -1.0 }, // 왼쪽 위로 이동
  child: child
})

// 한 방향만 이동
FractionalTranslation({ 
  translation: { x: 0, y: -0.5 },    // 위로만 절반 이동
  child: child
})

중앙 정렬 패턴

// 부모 중앙에 배치
Center({
  child: FractionalTranslation({
    translation: { x: -0.5, y: -0.5 },
    child: Container({
      width: 100,
      height: 100,
      color: 'blue'
    })
  })
})

주의사항

  • 히트 테스트는 원래 위치 경계에서만 작동합니다
  • 자식이 부모 영역을 벗어날 수 있으므로 클리핑을 고려해야 합니다
  • 과도한 이동은 사용자 경험을 해칠 수 있습니다
  • 애니메이션과 함께 사용할 때 성능을 고려해야 합니다
  • 음수 값 사용 시 방향을 정확히 이해해야 합니다

관련 위젯

  • Transform.translate: 절대적인 픽셀 단위 이동
  • Positioned: Stack 내에서의 절대 위치 지정
  • Align: 정렬 기반 위치 지정
  • AnimatedPositioned: 애니메이션이 포함된 위치 지정
  • Offset: 좌표 오프셋 표현 클래스