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);
}