HarmonyOS Next: 중앙이 오목한 탭바 커스텀 구현

HarmonyOS Next 개발 환경에서 독창적인 사용자 인터페이스를 구현하려면 때때로 기본 컴포넌트의 한계를 뛰어넘어야 합니다. 특히, 일반적인 직사각형 형태의 탭바(Tab Bar) 디자인에서 벗어나 시각적으로 특별한 요소를 추가하고 싶을 때, ArkUI Canvas를 활용한 커스텀 구현은 필수적인 접근 방식입니다.

이 가이드에서는 하단 중앙이 오목하게 들어간 형태의 독특한 탭바를 구현하는 과정을 상세히 설명합니다. 이러한 비정형 디자인은 사용자에게 새로운 시각적 경험을 제공할 수 있습니다.

완성된 중앙 오목 탭바의 모습
최종 구현된 중앙 오목형 탭바

1. 비정형 곡선 경로 정의

가장 핵심적인 과제는 중앙의 오목한 곡선 부분을 정확하게 그리는 것입니다. 시스템에서 제공하는 기본 Tabs 컴포넌트로는 이러한 복잡한 형태를 직접 표현하기 어렵기 때문에, Path 객체와 3차 베지어 곡선(Cubic Bezier Curve) 기능을 활용하여 직접 경로를 정의해야 합니다.

중앙 오목 곡선 부분의 기하학적 분석 다이어그램
곡선 부분을 구성하는 원형 세그먼트 분석

곡선 부분을 분석해보면, 이는 사실 여러 개의 원형 호(arc)들이 부드럽게 연결된 형태임을 알 수 있습니다. 이 곡선을 기하학적으로 분해하여 제어점(Control Point)과 끝점(End Point)을 계산하면, 베지어 곡선을 사용하여 정밀하게 그릴 수 있습니다. 이 가이드에서는 곡선을 세 개의 원형 세그먼트로 나누고, 각 세그먼트의 좌표를 계산하여 연결하는 방식을 사용합니다.

곡선 경로를 구성할 정점(Vertex)들을 계산하는 로직은 다음과 같습니다. 여기서 calculateYFromX 함수는 원의 방정식을 이용하여 주어진 x좌표에 대한 y좌표를 계산하는 가상의 헬퍼 함수입니다.


interface PointCoordinates {
  x: number;
  y: number;
}

// 곡선을 구성할 점의 밀도 (정확도)
const pointDensity = 24;
let curvePathPoints: PointCoordinates[] = [];

// 각 원형 세그먼트의 가상 반지름 및 중심 좌표 설정 (실제 값은 UI 디자인에 따라 조정)
const mainCircleR = 30; // 주요 원 반지름
const circle1Center = { x: 100, y: 50 }; // 첫 번째 원의 중심
const circle2Center = { x: 150, y: 20 }; // 중앙 오목 부분의 원의 중심 (가장 낮은 부분)
const circle3Center = { x: 200, y: 50 }; // 세 번째 원의 중심

// 각 세그먼트의 x축 시작점과 끝점 (전체 탭바 너비와 중앙 오목부 너비에 따라 계산)
const segmentOneEnd = 50; // 첫 번째 곡선 세그먼트의 상대적 x 끝점
const middleSectionWidth = 100; // 중앙 오목부의 총 너비

// 첫 번째 원형 세그먼트의 점 계산
for (let i = 0; i <= pointDensity; i++) {
  const currentX = segmentOneEnd * (i / pointDensity);
  // calculateYFromX 함수는 원의 방정식을 이용해 x좌표에 대한 y좌표를 반환합니다.
  // 예: this.calculateYFromX(중심X, 중심Y, 반지름, 현재X, 상단/하단)
  curvePathPoints.push(this.calculateYFromX(circle1Center.x, circle1Center.y, mainCircleR, currentX, true));
}

// 중앙 오목 부분 (두 번째 원형 세그먼트)의 점 계산
for (let i = 1; i <= pointDensity * 2; i++) { // 중간 부분은 더 많은 점으로 부드러움 확보
  const currentX = segmentOneEnd + (middleSectionWidth / 2) * (i / (pointDensity * 2));
  curvePathPoints.push(this.calculateYFromX(circle2Center.x, circle2Center.y, mainCircleR, currentX, false));
}

// 세 번째 원형 세그먼트의 점 계산
for (let i = 1; i <= pointDensity; i++) {
  const currentX = segmentOneEnd + (middleSectionWidth / 2) + (middleSectionWidth / 2) * (i / pointDensity);
  curvePathPoints.push(this.calculateYFromX(circle3Center.x, circle3Center.y, mainCircleR, currentX, true));
}

2. Canvas를 이용한 탭바 배경 그리기

계산된 좌표를 기반으로 CanvasRenderingContext2D 객체를 사용하여 실제 경로를 그리고 채워나갑니다. 탭바 상단에 그림자가 적용될 공간을 확보하기 위해 약간의 패딩을 둡니다.


@State private shadowAreaTopPadding: number = 6; // 상단 여백 (그림자 공간 확보)
@State private canvasDrawingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
@State private currentScreenWidth: number = 0; // 화면 너비 (실제 환경에서 초기화 필요)
@State private customTabBarHeight: number = 64; // 커스텀 탭바의 전체 높이

// ... (canvasDrawingContext 초기화 및 currentScreenWidth 설정 로직) ...

// 경로 시작
this.canvasDrawingContext.beginPath();

// 좌측 상단 패딩된 위치에서 시작
this.canvasDrawingContext.moveTo(0, this.shadowAreaTopPadding);

// 좌측 직선 경로
const startCurveXPos = curvePathPoints[0].x; // 곡선 시작점의 x좌표
this.canvasDrawingContext.lineTo(startCurveXPos, this.shadowAreaTopPadding);

// 곡선 경로 (베지어 곡선으로 점들을 연결)
// curvePathPoints 배열에서 점 3개씩 사용하여 3차 베지어 곡선 생성
for (let i = 1; i < curvePathPoints.length - 2; i += 3) {
  this.canvasDrawingContext.bezierCurveTo(
    curvePathPoints[i].x, curvePathPoints[i].y,
    curvePathPoints[i + 1].x, curvePathPoints[i + 1].y,
    curvePathPoints[i + 2].x, curvePathPoints[i + 2].y
  );
}

// 우측 직선 경로
const endCurveXPos = curvePathPoints[curvePathPoints.length - 1].x; // 곡선 끝점의 x좌표
this.canvasDrawingContext.lineTo(this.currentScreenWidth, this.shadowAreaTopPadding);

// 하단 직선 및 좌측 수직선으로 경로 닫기
this.canvasDrawingContext.lineTo(this.currentScreenWidth, this.customTabBarHeight);
this.canvasDrawingContext.lineTo(0, this.customTabBarHeight);
this.canvasDrawingContext.closePath(); // 경로를 닫아 도형 완성

// 배경 색상으로 채우기
this.canvasDrawingContext.fillStyle = '#ffffff'; // 흰색 배경
this.canvasDrawingContext.fill();

// 그림자 효과 적용
this.canvasDrawingContext.shadowOffsetY = 0;
this.canvasDrawingContext.shadowColor = '#949494'; // 회색 그림자
this.canvasDrawingContext.shadowBlur = 12; // 흐림 정도
this.canvasDrawingContext.stroke(); // 그림자 효과를 위해 테두리 선을 그립니다.

3. 탭 아이템 및 중앙 액션 버튼 배치

탭바 배경 위에 각 메뉴 아이템과 중앙의 특별한 액션 버튼을 배치해야 합니다. 이는 Stack 레이아웃을 사용하여 여러 UI 컴포넌트를 겹쳐 표현함으로써 구현할 수 있습니다. 중앙의 오목한 부분에 위치할 버튼은 시각적으로 강조되기 위해 크기를 키우고, 원형 테두리 및 그림자 효과를 추가합니다.


interface TabMenuItem {
  iconDefault: Resource;
  iconActive: Resource;
  textLabel: string;
}

@State private tabItems: TabMenuItem[] = [
  { iconDefault: $r('app.media.ic_home_inactive'), iconActive: $r('app.media.ic_home_active'), textLabel: '홈' },
  { iconDefault: $r('app.media.ic_search_inactive'), iconActive: $r('app.media.ic_search_active'), textLabel: '탐색' },
  // 중앙 버튼을 위한 자리 (실제로는 index로 처리)
  { iconDefault: $r('app.media.ic_placeholder'), iconActive: $r('app.media.ic_placeholder'), textLabel: '액션' },
  { iconDefault: $r('app.media.ic_message_inactive'), iconActive: $r('app.media.ic_message_active'), textLabel: '메시지' },
  { iconDefault: $r('app.media.ic_user_inactive'), iconActive: $r('app.media.ic_user_active'), textLabel: '내정보' }
];

@State private currentActiveIndex: number = 0; // 현재 활성화된 탭의 인덱스
@State private inactiveFontColor: Color = Color.Gray;
@State private activeFontColor: Color = Color.Black;

// CustomBottomBar 컴포넌트의 build() 함수 내부 (Row 레이아웃 사용)
Row {
  ForEach(this.tabItems, (item: TabMenuItem, idx: number) => {
    if (idx === Math.floor(this.tabItems.length / 2)) { // 중앙 인덱스에 해당하는 버튼 처리
      Image($r('app.media.ic_action_center')) // 중앙 특별 버튼 이미지
        .width(60)
        .height(60)
        .borderRadius(30)
        .offset({ y: -30 }) // 상단으로 오프셋하여 오목한 부분에 돌출
        .borderWidth(4)
        .borderColor(Color.White)
        .borderStyle(BorderStyle.Solid)
        .shadow({
          radius: 20,
          color: Color.Gray,
          offsetX: 0,
          offsetY: 0
        })
        .onClick(() => {
          // 중앙 버튼 클릭 시 특정 액션 페이지로 이동
          this.onTabItemClicked(-1); // -1은 중앙 버튼을 의미
        });
    } else {
      Column({ space: 4 }) {
        Image(this.currentActiveIndex === idx ? item.iconActive : item.iconDefault)
          .width(22)
          .height(22)
          .objectFit(ImageFit.Contain);
        Text(item.textLabel)
          .fontSize(13)
          .fontColor(this.currentActiveIndex === idx ? this.activeFontColor : this.inactiveFontColor);
      }
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.currentActiveIndex = idx;
        this.onTabItemClicked(idx);
      });
    }
  });
}
.justifyContent(FlexAlign.SpaceAround) // 아이템들을 가로로 균등하게 배치
.width('100%');

4. 시스템 Tabs와의 연동

커스텀 탭바는 화면 하단의 시각적 요소일 뿐, 실제 페이지 전환 로직은 HarmonyOS의 기본 Tabs 컴포넌트가 담당하도록 합니다. Tabs 컴포넌트의 tabBar 속성을 설정하지 않으면 하단에 내용 없는 영역이 생기는데, 이 공간에 커스텀 탭바를 배치하여 사용합니다.

시스템 Tabs 컴포넌트와 커스텀 탭바의 레이아웃 통합 예시
시스템 Tabs 컴포넌트 위에 커스텀 탭바를 배치한 구조

Stack 컨테이너를 사용하여 시스템 Tabs 위에 커스텀 탭바 컴포넌트(CustomBottomBar)를 오버레이하는 방식으로 통합합니다. TabsbarHeight 속성을 조절하여 커스텀 탭바가 위치할 공간을 확보합니다.


// MainEntry.ets (예시)
@Entry
@Component
struct MainEntryView {
  @State private currentContentPageIndex: number = 0;
  private appTabController: TabsController = new TabsController();

  build() {
    Stack({ alignContent: Alignment.Bottom }) { // 하단 정렬 스택
      // 1. 실제 페이지 내용을 관리하는 시스템 Tabs 컴포넌트
      Tabs({ barPosition: BarPosition.End, controller: this.appTabController }) {
        TabContent() { ContentPageOne() } // 첫 번째 페이지
        TabContent() { ContentPageTwo() } // 두 번째 페이지
        TabContent() { ContentPagePlaceholder() } // 중앙 버튼 클릭 시 이동할 페이지의 자리 (또는 빈 페이지)
        TabContent() { ContentPageThree() } // 세 번째 페이지
        TabContent() { ContentPageFour() } // 네 번째 페이지
      }
      .backgroundColor(Color.White)
      .barHeight(64) // 탭바 영역의 높이 (커스텀 탭바가 들어갈 공간을 확보)
      .onChange((index) => {
        this.currentContentPageIndex = index; // 페이지 전환 시 인덱스 업데이트
      })
      .animationDuration(1) // 페이지 전환 애니메이션
      .scrollable(false); // 탭 내용 스크롤 방지

      // 2. 위에서 정의한 커스텀 탭바 컴포넌트 배치
      CustomBottomBar({
        // CustomBottomBar에 전달할 메뉴 아이템 정의 (MainEntryView에서 관리 또는 하위 컴포넌트 내부에서 정의)
        tabItems: [
          { iconDefault: $r('app.media.ic_home_inactive'), iconActive: $r('app.media.ic_home_active'), textLabel: '홈' },
          { iconDefault: $r('app.media.ic_search_inactive'), iconActive: $r('app.media.ic_search_active'), textLabel: '탐색' },
          { iconDefault: $r('app.media.ic_placeholder'), iconActive: $r('app.media.ic_placeholder'), textLabel: '액션' },
          { iconDefault: $r('app.media.ic_message_inactive'), iconActive: $r('app.media.ic_message_active'), textLabel: '메시지' },
          { iconDefault: $r('app.media.ic_user_inactive'), iconActive: $r('app.media.ic_user_active'), textLabel: '내정보' }
        ],
        currentActiveIndex: this.currentContentPageIndex,
        onTabItemClicked: (index: number) => {
          if (index === -1) { // 중앙 버튼 클릭 시
            router.pushUrl({ url: 'pages/ActionDetailPage' }); // 특정 액션 상세 페이지로 이동
          } else {
            this.appTabController.changeIndex(index); // 시스템 Tabs의 페이지 전환
          }
        }
      });
    }
    .height('100%')
    .width('100%');
  }
}

// CustomBottomBar 컴포넌트 정의 (별도 파일 또는 MainEntryView 내부에 정의)
@Component
struct CustomBottomBar {
  @Prop tabItems: TabMenuItem[];
  @Prop currentActiveIndex: number;
  onTabItemClicked: (index: number) => void;

  // Canvas 관련 상태 및 로직은 여기에 포함
  @State private shadowAreaTopPadding: number = 6;
  @State private canvasDrawingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
  @State private currentScreenWidth: number = 0; // AppStorage.SetOrCreate('screenWidth', 0).get();
  @State private customTabBarHeight: number = 64;

  // ... (curvePathPoints 계산, calculateYFromX 함수 등) ...
  // drawCustomBackground() 함수도 이 컴포넌트 내부에 정의

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Canvas()
        .onReady((context: CanvasRenderingContext2D) => {
          this.canvasDrawingContext = context;
          this.drawCustomBackground(); // 배경 그리는 함수 호출
        })
        .width('100%')
        .height(this.customTabBarHeight);

      // 탭 아이템 Row는 위 코드 스니펫 참고
      Row {
        ForEach(this.tabItems, (item: TabMenuItem, idx: number) => {
          // ... (탭 아이템 UI 및 클릭 로직) ...
        });
      }
      .justifyContent(FlexAlign.SpaceAround)
      .width('100%')
      .height(this.customTabBarHeight);
    }
    .width('100%')
    .height(this.customTabBarHeight);
  }

  drawCustomBackground() {
    // Canvas 배경 그리기 로직 (위의 2. Canvas를 이용한 탭바 배경 그리기 섹션 참조)
    // 이 함수 내에서 this.canvasDrawingContext를 사용하여 경로를 그립니다.
  }
}

이와 같은 방식으로 HarmonyOS Next에서 기본 컴포넌트의 제약을 넘어선 독특하고 시각적으로 매력적인 탭바를 구현할 수 있습니다.

태그: HarmonyOS ArkUI Custom UI canvas Bezier Curve

7월 22일 19:37에 게시됨