Implementación de una Barra Lateral para Reproductores de Música con SideBarContainer en HarmonyOS NEXT

El componente SideBarContainer de HarmonyOS NEXT es una herramienta fundamental para diseñar interfaces que requieren navegación lateral persistente o bajo demanda. En aplicaciones multimedia, como un reproductor de música, este componente permite organizar colecciones de canciones y listas de reproducción de manera accesible sin interrumpir la vista principal de control.

Configuración del SideBarContainer

Este contenedor soporta tres modos de visualización principales que determinan cómo interactúa la barra lateral con el conetnido principal:

  • Embed: La barra lateral se integra en el diseño y desplaza el contenido principal para compartir el espacio.
  • Overlay: El menú lateral flota sobre el contenido principal, cubriéndolo parcialmente.
  • Auto: El sistema decide el modo basado en las dimensiones y la orientación del dispositivo.

Para un reproductor de escritorio o tablet, el modo Embed suele ser el preferido, ya que permite al usuario gestionar su cola de reproducción mientras mantiene el control total de la pista actual.

Estructura Base del Reproductor

A continuación, se presenta una estructura optimizada para un reproductor de audio utilizando ArkTS. Hemos definido estados para controlar la visibilidad del menú, la pista actual y el estado de la reproducción.

@Entry
@Component
struct ReproductorAudio {
  @State mostrarMenu: boolean = true
  @State pistaActiva: number = 0
  @State enReproduccion: boolean = false
  @State tiempoTranscurrido: number = 0
  @State nombreListaActual: string = 'Favoritos'

  build() {
    SideBarContainer(SideBarContainerType.Embed) {
      // Sección 1: Navegación y Listas
      this.ConstructorMenuLateral()

      // Sección 2: Panel de Control Principal
      this.ConstructorVistaPrincipal()
    }
    .controlButton({
      icons: {
        shown: $r('app.media.btn_cerrar_menu'),
        hidden: $r('app.media.btn_abrir_menu'),
      }
    })
    .sideBarWidth(280)
    .minSideBarWidth(140)
    .maxSideBarWidth(450)
    .onChange((estado: boolean) => {
      this.mostrarMenu = estado
    })
  }

  @Builder ConstructorMenuLateral() {
    // Definición del menú lateral
  }

  @Builder ConstructorVistaPrincipal() {
    // Definición del área de reproducción
  }
}

Gestión de Datos y Modelos

Para alimentar la interfaz, definimos interfaces claras que representen la estructura de la música y las colecciones.

interface InfoCancion {
  titulo: string
  interprete: string
  portada: Resource
  duracionSegundos: number
}

interface InfoAlbum {
  titulo: string
  imagen: Resource
  totalPistas: number
}

// Datos de ejemplo
private catalogoPistas: InfoCancion[] = [
  { titulo: 'Luz de Luna', interprete: 'Artista A', portada: $r('app.media.cover1'), duracionSegundos: 215 },
  { titulo: 'Horizonte Azul', interprete: 'Artista B', portada: $r('app.media.cover2'), duracionSegundos: 198 },
  { titulo: 'Eco Eterno', interprete: 'Artista C', portada: $r('app.media.cover3'), duracionSegundos: 245 }
]

Interfaz del Menú Lateral: Listas y Cola

El menú lateral se divide en secciones jerárquicas: primero las colecciones globales y luego la cola de reproducción actual.

@Builder ConstructorMenuLateral() {
  Column() {
    Text('Mi Biblioteca')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .margin({ top: 25, bottom: 15, left: 20 })

    List() {
      // Cabecera de sección
      ListItem() {
        Text('LISTAS DE REPRODUCCIÓN')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ left: 20, bottom: 10 })
      }

      // Renderizado de canciones en la cola
      ForEach(this.catalogoPistas, (pista: InfoCancion, indice: number) => {
        ListItem() {
          Row() {
            Stack() {
              Image(pista.portada)
                .width(45)
                .height(45)
                .borderRadius(6)
              
              if (this.pistaActiva === indice && this.enReproduccion) {
                Rect().fill('#80000000').width(45).height(45).borderRadius(6)
                // Aquí se podría añadir una animación de ondas
              }
            }
            .margin({ right: 12 })

            Column() {
              Text(pista.titulo)
                .fontSize(15)
                .fontColor(this.pistaActiva === indice ? '#007DFF' : '#1A1A1A')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })

              Text(pista.interprete)
                .fontSize(12)
                .fontColor('#999999')
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .padding({ left: 15, right: 15, top: 10, bottom: 10 })
          .backgroundColor(this.pistaActiva === indice ? '#F2F2F2' : Color.Transparent)
        }
        .onClick(() => {
          this.pistaActiva = indice
          this.tiempoTranscurrido = 0
          this.enReproduccion = true
        })
      })
    }
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FAFAFA')
}

Contenido Principal: Reproductor y Controles

La vista principal utiliza el espacio restante para mostrar la información detallada de la pista y los controles de transporte.

@Builder ConstructorVistaPrincipal() {
  Column() {
    // Portada principal
    Image(this.catalogoPistas[this.pistaActiva].portada)
      .width(320)
      .height(320)
      .borderRadius(20)
      .shadow({ radius: 15, color: '#20000000', offsetX: 0, offsetY: 10 })
      .margin({ top: 60 })

    Text(this.catalogoPistas[this.pistaActiva].titulo)
      .fontSize(28)
      .fontWeight(FontWeight.Bold)
      .margin({ top: 30 })

    Text(this.catalogoPistas[this.pistaActiva].interprete)
      .fontSize(18)
      .fontColor('#666666')
      .margin({ top: 10 })

    // Deslizador de progreso
    Column() {
      Slider({
        value: this.tiempoTranscurrido,
        min: 0,
        max: this.catalogoPistas[this.pistaActiva].duracionSegundos,
        style: SliderStyle.InSet
      })
      .blockColor('#007DFF')
      .trackColor('#E5E5E5')
      .selectedColor('#007DFF')
      .width('85%')
      .onChange((val: number) => {
        this.tiempoTranscurrido = val
      })

      Row() {
        Text(this.formatearTiempo(this.tiempoTranscurrido))
        Blank()
        Text(this.formatearTiempo(this.catalogoPistas[this.pistaActiva].duracionSegundos))
      }
      .width('85%')
      .margin({ top: 5 })
    }
    .margin({ top: 40 })

    // Botonera de control
    Row() {
      Button({ type: ButtonType.Circle }) {
        Image($r('app.media.skip_back')).width(30)
      }.backgroundColor(Color.Transparent).onClick(() => this.cambiarPista(-1))

      Button({ type: ButtonType.Circle }) {
        Image(this.enReproduccion ? $r('app.media.pause') : $r('app.media.play')).width(50)
      }
      .width(80).height(80)
      .margin({ left: 30, right: 30 })
      .backgroundColor('#007DFF')
      .onClick(() => this.enReproduccion = !this.enReproduccion)

      Button({ type: ButtonType.Circle }) {
        Image($r('app.media.skip_forward')).width(30)
      }.backgroundColor(Color.Transparent).onClick(() => this.cambiarPista(1))
    }
    .margin({ top: 50 })
  }
  .width('100%')
  .height('100%')
}

Lógica de Apoyo

Para que la interfaz sea funcional, se requieren métodos de utilidad que gestionen el formato de tiempo y la lógica de navegación entre pistas.

private formatearTiempo(segundos Totales: number): string {
  const min = Math.floor(segundosTotales / 60)
  const seg = Math.floor(segundosTotales % 60)
  return `${min}:${seg.toString().padStart(2, '0')}`
}

private cambiarPista(direccion: number) {
  let nuevoIndice = this.pistaActiva + direccion
  if (nuevoIndice < 0) {
    nuevoIndice = this.catalogoPistas.length - 1
  } else if (nuevoIndice >= this.catalogoPistas.length) {
    nuevoIndice = 0
  }
  this.pistaActiva = nuevoIndice
  this.tiempoTranscurrido = 0
  this.enReproduccion = true
}

Consideraciones de Diseño

Al implementar este tipo de navegación, es importante considerar:

  1. Restricciones de Ancho: Utilizar minSideBarWidth y maxSideBarWidth evita que el usuario colapse visualmente el menú o que este invada demasiado el área principal en pantallas muy grandes.
  2. Retroalimentación Visual: El uso de cambios de color en el fondo (backgroundColor) y en la fuente para el elemento activo ayuda al usuario a identificar rápidamente qué se está reproduciendo.
  3. Optimización de Texto: En la barra lateral, el espacio es limitado. El uso de textOverflow con puntos suspensivos previene que títulos largos rompan la estructura del ListItem.

Etiquetas: HarmonyOS SideBarContainer ArkTS UI Design Mobile Development

Publicado el 7-14 04:50