DEV Community

HarmonyOS
HarmonyOS

Posted on

Implementing Custom SideBar with Bottom Content Translation and Gesture Control

Read the original article:Implementing Custom SideBar with Bottom Content Translation and Gesture Control

Context

When developing applications with sidebar navigation in HarmonyOS, the standard SideBarContainer component has limitations. Developers often need more advanced behaviors such as having the bottom content translate smoothly when the sidebar appears, implementing gesture-based sliding where the sidebar follows finger movement, and automatically collapsing the sidebar when the user releases with a leftward swipe tendency. The built-in SideBarContainer only supports three display modes (Embed, Overlay, and AUTO) which cannot achieve these custom interaction patterns.

Description

The standard SideBarContainer component provides three SideBarContainerType styles:

  • Embed: The sidebar is embedded within the component, displayed alongside the content area. When component size is less than minContentWidth + minSideBarWidth and showSideBar is not set, the sidebar automatically hides. After auto-hiding, clicking the control button makes the sidebar float over the content area.
  • Overlay: The sidebar floats over the content area.
  • AUTO: Uses Embed mode when component size is greater than or equal to minSideBarWidth + minContentWidth, otherwise uses Overlay mode. If the calculated value is less than 600vp, 600vp is used as the mode switching breakpoint.

These built-in modes cannot satisfy requirements for:

  • Bottom content translating with sidebar appearance
  • Sidebar following finger gestures during drag
  • Automatic collapse based on swipe velocity and direction

To achieve these advanced interactions, developers need to leverage:

  • Stack: A stacking container where child components are stacked in order, with later children covering earlier ones
  • transition: Component transition animations configured through the transition attribute for smooth insertion and deletion effects
  • PanGesture: Pan gesture events triggered when the minimum sliding distance reaches the set threshold

Solution

Since SideBarContainer cannot meet these requirements, the solution uses a Stack layout to layer three elements: the main interface (bottom), a semi-transparent gray mask (middle), and the sidebar (top). The implementation uses TransitionEffect.move for bottom content translation and PanGesture for gesture-based sliding effects.

Complete Implementation:

@Component
export struct SideBar {
  build() {
    Column() {
      Text('Side Bar')
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
        .onClick(() => {
        })
    }
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F1F3F5')
    .height('100%')
    .width('100%')
  }
}

@Entry
@Component
struct Index {
  @State showSideBar: boolean = false; 
  @State isLeft: boolean = false; 
  sideBarWidth = 290;
  duration = 250; 
  @State xValue: number = 0;  
  maxX: number = this.sideBarWidth;  
  minX: number = 0;  

  build() {
    Stack({ alignContent: this.isLeft ? Alignment.TopStart : Alignment.TopEnd }) {
      Column() {
        Text('Open Side Open Side Open Side Open Side Open Side')
          .fontSize(30)
          .onClick(() => {
            this.getUIContext().animateTo({
              duration: this.duration,
              curve: Curve.EaseOut,
            }, () => {
              this.showSideBar = true
              this.xValue = this.sideBarWidth
            })
          })
          .backgroundColor('#FBC803')
      }
      .justifyContent(FlexAlign.Center)
      .margin({
        right: !this.isLeft ? this.xValue : 0,
        left: this.isLeft ? this.xValue : 0,
      })
      .backgroundColor('#0A59F7')
      .height('100%')
      .width('100%')
      if (this.showSideBar) {
        Column()
          .backgroundColor('#000')
          .opacity(0.4)
          .height('100%')
          .width('100%')
          .onClick(() => {
            this.getUIContext().animateTo({
              duration: this.duration,
              curve: Curve.EaseOut,
            }, () => {
              this.showSideBar = false
              this.xValue = 0
            })
          })
      }

      if (this.showSideBar) {
        Column() {
          SideBar()
        }
        .margin({
          left: this.isLeft ? this.xValue - this.sideBarWidth : 0,
          right: !this.isLeft ? this.xValue - this.sideBarWidth : 0,
        })
        .width(this.sideBarWidth)
        .visibility(this.showSideBar ? Visibility.Visible : Visibility.Hidden)
        .transition(TransitionEffect.OPACITY.animation({ duration: this.duration, curve: Curve.EaseInOut })
          .combine(TransitionEffect.move(this.isLeft ? TransitionEdge.START : TransitionEdge.END)))
        .gesture(PanGesture(new PanGestureOptions({ direction: PanDirection.Left | PanDirection.Right }))
          .onActionUpdate((event: GestureEvent) => {
            if (event) {
              let a = this.isLeft ? this.maxX + event.offsetX : this.maxX - event.offsetX
              if (this.xValue >= this.maxX) {
                this.xValue = a >= this.maxX ? this.maxX : a;
              } else if (this.xValue <= this.minX) {
                this.xValue = a <= this.minX ? this.minX : a;
              } else {
                this.xValue = a
              }
            }
          })
          .onActionEnd((event: GestureEvent) => {
            if (event.velocityX > 0) {
              this.getUIContext().animateTo({
                duration: 250,
                curve: Curve.EaseOut,
              }, () => {
                this.xValue = this.isLeft ? this.maxX : this.minX;
                this.showSideBar = this.isLeft ? true : false;
              })
            } else {
              this.getUIContext().animateTo({
                duration: 250,
                curve: Curve.EaseOut,
              }, () => {
                this.xValue = this.isLeft ? this.minX : this.maxX;
                this.showSideBar = this.isLeft ? false : true;
              })
            }
          })
        )
      }
    }
    .height('100%')
    .width('100%')
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Details:

  1. Stack Layout Structure: Three layers are stacked - main content (bottom), semi-transparent mask (middle), and sidebar (top)
  2. Content Translation: The main content uses margin with xValue to translate left or right based on sidebar position
  3. Gesture Handling:
    • onActionUpdate: Updates xValue based on finger drag position, clamped between minX and maxX
    • onActionEnd: Checks velocityX to determine swipe direction and animates sidebar to fully open or closed state
  4. Direction Control: Set isLeft to true for left-side sidebar or false for right-side sidebar
  5. Mask Interaction: Clicking the semi-transparent mask closes the sidebar with animation

Key Takeaways

  • Use Stack layout to layer multiple components when SideBarContainer cannot meet custom interaction requirements
  • Combine TransitionEffect.move with margin adjustments to create smooth content translation effects when sidebar appears
  • Implement PanGesture with onActionUpdate to make UI follow finger movement in real-time during drag gestures
  • Use event.velocityX in onActionEnd to determine swipe intention and automatically complete the open/close action
  • Clamp gesture values between min and max bounds to prevent content from moving beyond desired limits
  • Add a semi-transparent mask layer to improve visual hierarchy and provide an intuitive close interaction
  • The isLeft boolean flag makes the component flexible for both left and right sidebar implementations
  • Use animateTo with appropriate duration and curve for smooth, professional-looking transitions

Written by Emincan Ozcan

Top comments (0)