Building Interactive Components for HarmonyOS Applications

This guide demonstrates the construction of an interactive quiz application on HarmonyOS, focusing on component-based UI development and data flow management.

Quiz Interface Assembly

The quiz interface leverages a component-based architecture. Individual modules are encapsulated as components for reusability and to streamline the main UI. Compared to the index screen, the quiz screen's code is more concise, with the title, progress bar, and question/answer area abstracted into separate components. These are then imported and utilized by passing the necessary parameters.

// Topic.ets
import { TitleComponent } from '../component/TitleComponent';
import { ProgressComponent } from '../component/ProgressComponent';
import { TopicBodyComponent } from '../component/TopicBodyComponent';
import router from '@ohos.router';

@Entry
@Component
struct Topic {
  @State paramsFromIndex: Record<string, unknown> = router.getParams();
  @State finishTopic: number = 0;
  @State allTopic: number = 0;

  build() {
    Column({ space: 20 }) {
      // Title
      TitleComponent({ title: `${this.paramsFromIndex?.['args2']} Test ` });
      // Progress Bar
      ProgressComponent({ finishTopic: this.finishTopic, allTopic: this.allTopic });
      // Quiz Area
      TopicBodyComponent({ finishTopic: $finishTopic, allTopic: $allTopic });
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f0f0f0');
  }
}

In this structure, Topic.ets acts as the parent component, while the title, progress bar, and answer area are child components. Upon entering this screen, initial data is received from the index screen to customize the title. Since the title is purely for display and not modified within the child component, this represents a one-way data flow from parent to child. The @Prop decorator is used for receiving these props.

/*** 
 * Top status bar component
 */
import router from '@ohos.router';

@Component
export struct TitleComponent {
  @Prop title: string; // One-way data flow: Parent -> Child

  build() {
    Row({ space: 22 }) {
      Image($r('app.media.left'))
        .width(50)
        .height(50)
        .fillColor(Color.White)
        .margin({ left: 15 })
        .onClick(() => {
          router.back();
        });
      Text(this.title).fontSize(20);
    }
    .height('10%')
    .width('100%')
    .border({ width: 1 });
  }
}

Before rendering the question and answer components, background data is fetched. This involves making a network request to retrieve the list of questions and the total count. As the user answers each question, the completed count increments. Because the parent and this component utilize a two-way data binding via @Link, changes within this component are reflected in the parent.

// Network request before rendering
aboutToAppear(): void {
  let questionList: Array<object> = [];

  // Fetch data from network
  const httpRequest = http.createHttp();
  httpRequest.request('localhost:8899/homp/getAll', (err, data) => {
    if (!err) {
      // Parse data
      const response = data.result.toString();
      const res = JSON.parse(response).data;

      for (let i = 0; i < res.length; i++) {
        let item = res[i];
        questionList.push({
          id: item.sequenceNumber,
          name: item.name,
          optionA: item.optionA,
          valA: item.valA,
          optionB: item.optionB,
          valB: item.valB,
        });
      }
      this.allTopic = res.length; // Update total question count
    } else {
      console.info('Error fetching data: ' + JSON.stringify(err));
    }
  });

  this.data = new MyDataSource(questionList);
}

Note: Network requests require explicit permission. Add the following to src/main/module.json5:

// Network permission
"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "usedScene": {
      "when": "always"
    }
  }
]

The main question-answer area utilizes a Swiper component to facilitate automatic page turning after each answer. The fetched data is rendered in a loop. Once the user reaches the final question and answers it, the application navigates to the results screen to display the personality test outcome computed by the backend.

// Question Answer Area
Swiper(this.swiperController) {
  LazyForEach(this.data, (item: Question) => {
    // Nested component for displaying questions
    Column() {
      // Question stem
      Text(`${item.id}. ${item.name}`);

      // Option A
      Button({
        // ... button content ...
      })
      .onClick(() => {
        const index = Number(item.id);
        this.finishTopic = index;
        if (index === this.data.totalCount()) {
          // Save choice
          this.userAnswers.push(item.valA);
          const aggregatedAnswers = this.userAnswers.join('');
          console.log('Answers:' + aggregatedAnswers);
          // Navigate to results page
          router.replaceUrl({
            url: 'pages/ShowResPage',
            params: {
              ans: aggregatedAnswers,
            },
          });
        } else {
          // Save choice
          this.userAnswers.push(item.valA);
          // Move to next question
          this.swiperController.showNext();
        }
      });

      // Option B
      Button({
        // ... button content ...
      })
      .onClick(() => {
        const index = Number(item.id);
        this.finishTopic = index;
        if (index === this.data.totalCount()) {
          // Save choice
          this.userAnswers.push(item.valB);
          const aggregatedAnswers = this.userAnswers.join('');
          console.log('Answers:' + aggregatedAnswers);
          // Navigate to results page
          router.replaceUrl({
            url: 'pages/ShowResPage',
            params: {
              ans: aggregatedAnswers,
            },
          });
        } else {
          // Save choice
          this.userAnswers.push(item.valB);
          // Move to next question
          this.swiperController.showNext();
        }
      });
    }
    .width('90%')
    .height(180)
    .justifyContent(FlexAlign.SpaceEvenly);
  }, item => item.id); // Use item.id for key
}
.cardStyle()
.cachedCount(2)
.index(0)
.interval(4000)
.indicator(false)
.loop(false)
.duration(1000)
.itemSpace(0)
.disableSwipe(true)
.curve(Curve.Linear)
.onChange((index: number) => {
  // console.info(index.toString() + this.res.join(''))
});

The progress component is updated as the user answers questions. Since the finishTopic variable is linked bidirectionally via @Link with the answer component, changes to it are reflected, and this update is then passed unidirectionally to the ProgressComponent, ensuring the progress display remains accurate.

/*** 
 * Progress module within the question list
 */
@Component
export struct ProgressComponent {
  @Prop finishTopic: number;
  @Prop allTopic: number;

  build() {
    Column() {
      Row() {
        Text('Progress: ')
          .fontSize(20)
          .fontWeight(FontWeight.Bold);
        Stack() { // Stack to display progress bar and text
          Progress({
            value: this.finishTopic,
            total: this.allTopic,
            type: ProgressType.Ring,
          })
          .width(80);
          Row() {
            Text(this.finishTopic.toString()).fontWeight(18).fontColor('#36D');
            Text(` / ${this.allTopic.toString()}`).fontWeight(18).fontColor(Color.Black);
          }
        }
      }
      .cardStyle()
      .margin({ top: 15, left: 10, right: 10 })
      .justifyContent(FlexAlign.SpaceEvenly)
      .backgroundColor('#FAEBD7');
    }
  }
}

@Styles function cardStyle() {
  .width('95%')
  .padding(20)
  .backgroundColor(Color.White)
  .borderRadius(15)
  .shadow({ radius: 6, color: '#1F000000', offsetX: 2, offsetY: 4 });
}

Results Display Interface Assembly

Similar to the quiz interface, the results display page also utilizes custom components for reusability. To accommodate potentially lengthy content, the entire page is wrapped in a Scroll component. This scrollable layout consists of three sections: a header, an image display area, and a footer containing attribute details.

Before rendering, this page receives the answer string from the quiz interface. This string is then sent via a POST request to the backend, which processes it to return the test results and personality attributes.

// POST request for results
aboutToAppear() {
  const httpRequest = http.createHttp();
  httpRequest.request('localhost:8899/homp/submit', {
    method: http.RequestMethod.POST,
    extraData: {
      "ans": this.paramsFromIndex?.['ans'],
    }
  }, (err, data) => {
    if (!err) {
      const response = data.result.toString();
      const res = JSON.parse(response).data;

      this.resultDisplay = res;
      console.log('Result data: ' + this.resultDisplay.result);
      this.resultString = this.resultDisplay.result;
      console.log('Result string: ' + this.resultString);
    }
  });
}

The three sections correspond to custom components. The title component has already been discussed. The focus here is on the image and attribute panel components.

Image Component After receiving the test results from the backend, the personality abbreviation is exrtacted and passed to the ImageComponent. This component then constructs the resource path for the corresponding personality image and displays it. Simple animations are also applied to the image.

@Component
export struct ImageComponent {
  @State isFlipped: boolean = false;
  @Prop personalityAbbr: string;

  build() {
    Row() {
      Column() {
        Image($rawfile(`${this.personalityAbbr}.png`))
          .width('60%')
          .height('60%')
          .objectFit(ImageFit.Contain)
          .rotate({
            x: 0,
            y: 1,
            z: 0,
            angle: this.isFlipped ? 360 : 0,
          })
          .scale(
            this.isFlipped
              ? { x: 1.25, y: 1.25 }
              : { x: 1, y: 1 }
          )
          .opacity(this.isFlipped ? 0.6 : 1)
          .onClick(() => {
            this.isFlipped = !this.isFlipped;
          })
          .animation({
            delay: 10,
            duration: 1000,
            iterations: 1,
            curve: Curve.Smooth,
            playMode: PlayMode.Normal,
          });
        Text(this.personalityAbbr)
          .fontSize(25)
          .width('90%')
          .height('20%')
          .decoration({
            type: TextDecorationType.Underline,
            color: Color.Orange,
          })
          .fontWeight(FontWeight.Bold)
          .textAlign(TextAlign.Center);
      }.justifyContent(FlexAlign.SpaceEvenly);
    }
    .height('30%')
    .cardStyle()
    .margin({ top: 15 })
    .alignItems(VerticalAlign.Center)
    .justifyContent(FlexAlign.Center);
  }
}

Properties Panel Component This component receives the entire results object using @ObjectLink. It then extracts and renders specific fields, including percentage-based attributes displayed using HarmonyOS's built-in Slider component.

import { ResultData } from '../data/ResultData'; // Assuming ResultData is a defined type

@Component
export struct PropertiesPanelComponent {
  @ObjectLink testResults: ResultData;

  build() {
    Column() {
      Column() {
        Row({
          // ... slider for 'erate'
        }).width('80%').justifyContent(FlexAlign.SpaceAround);

        Row({
          // ... slider for 'irate'
        }).width('80%').justifyContent(FlexAlign.SpaceAround);

        // ... other sliders for rates like srate, nrate, trate, frate, jrate, prate ...

      }
      .cardStyle()
      .margin({ top: 15 });

      Column() {
        Text('Disc').fontSize(12).margin({ bottom: 15 });
        Text(this.testResults.disc)
          .fontSize(14)
          .maxLines(15)
          .lineHeight(20);
      }
      .cardStyle()
      .margin({ top: 15 })
      .justifyContent(FlexAlign.SpaceAround);
    }
    .width('100%')
    .height('80%')
    .alignItems(HorizontalAlign.Center);
  }
}

@Extend(Slider) function panelSliderStyle() {
  .width('50%')
  .blockColor('#ffefeff5')
  .trackColor('#ADD8E6')
  .selectedColor('#ff1686')
  .showTips(true)
  .enabled(false);
}

Tags: HarmonyOS Development UI Components Data Binding Mobile App

Posted on Sun, 10 May 2026 09:57:20 +0000 by el_kab0ng