68.[HarmonyOS NEXT 实战案例八] 电影票务网格布局(下)

简介: 在上一篇教程中,我们学习了如何使用GridRow和GridCol组件实现基本的电影票务网格布局。本篇教程将在此基础上,深入探讨如何优化布局、添加交互功能,以及实现更多高级特性,打造一个功能完善的电影票务应用。

[HarmonyOS NEXT 实战案例八] 电影票务网格布局(下)

项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star

效果演示

img_aaee4058.png

1. 概述

在上一篇教程中,我们学习了如何使用GridRow和GridCol组件实现基本的电影票务网格布局。本篇教程将在此基础上,深入探讨如何优化布局、添加交互功能,以及实现更多高级特性,打造一个功能完善的电影票务应用。

本教程将涵盖以下内容:

  • 响应式布局设计
  • 电影卡片优化
  • 电影详情页实现
  • 电影筛选和排序功能
  • 购票流程设计

2. 响应式布局设计

2.1 使用断点适配不同屏幕尺寸

在实际应用中,我们需要考虑不同屏幕尺寸的设备,如手机、平板等。GridRow组件提供了断点系统,可以根据屏幕宽度自动调整列数:

GridRow({
   
    columns: {
    xs: 1, sm: 2, md: 3, lg: 4, xl: 4, xxl: 6 },
    gutter: {
    x: 16, y: 16 }
}) {
   
    // 电影网格内容
}

这样配置后,在不同屏幕宽度下,网格列数会自动调整:

  • 极小屏幕(xs):1列
  • 小屏幕(sm):2列
  • 中等屏幕(md):3列
  • 大屏幕(lg)和特大屏幕(xl):4列
  • 超大屏幕(xxl):6列

2.2 使用GridCol的span属性实现不同大小的卡片

我们可以使用GridCol的span属性,为热门电影创建更大的卡片:

ForEach(this.movies, (movie: MovieType, index: number) => {
   
    GridCol({
   
        span: index === 0 ? {
    xs: 1, sm: 2, md: 2, lg: 2 } : {
    xs: 1, sm: 1, md: 1, lg: 1 }
    }) {
   
        // 电影卡片内容
    }
})

这样配置后,第一部电影(索引为0)的卡片在小屏幕及以上尺寸会占据2列,其他电影卡片占据1列,形成突出热门电影的效果。

3. 电影卡片优化

3.1 添加阴影和悬浮效果

为电影卡片添加阴影和悬浮效果,提升用户体验:

Column() {
   
    // 电影卡片内容
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({
   
    radius: 4,
    color: '#1A000000',
    offsetX: 0,
    offsetY: 2
})
.stateStyles({
   
    pressed: {
   
        scale: {
    x: 0.95, y: 0.95 },
        opacity: 0.8,
        translate: {
    x: 0, y: 2 }
    },
    normal: {
   
        scale: {
    x: 1, y: 1 },
        opacity: 1,
        translate: {
    x: 0, y: 0 }
    }
})
.animation({
   
    duration: 200,
    curve: Curve.EaseOut
})

这段代码为电影卡片添加了以下效果:

  • 白色背景和圆角
  • 轻微的阴影效果
  • 按下时的缩放和位移动画

3.2 添加标签和徽章

为电影卡片添加标签和徽章,显示更多信息:

Stack() {
   
    Image(movie.poster)
        .width('100%')
        .aspectRatio(0.7)
        .borderRadius({
    topLeft: 8, topRight: 8 })

    // 电影类型标签
    Text(movie.type)
        .fontSize(12)
        .fontColor(Color.White)
        .backgroundColor('#FF5722')
        .borderRadius(4)
        .padding({
    left: 6, right: 6, top: 2, bottom: 2 })
        .position({
    x: 8, y: 8 })

    // IMAX标签(如果适用)
    if (movie.isImax) {
   
        Text('IMAX')
            .fontSize(12)
            .fontColor(Color.White)
            .backgroundColor('#1976D2')
            .borderRadius(4)
            .padding({
    left: 6, right: 6, top: 2, bottom: 2 })
            .position({
    x: 8, y: 36 })
    }

    // 3D标签(如果适用)
    if (movie.is3d) {
   
        Text('3D')
            .fontSize(12)
            .fontColor(Color.White)
            .backgroundColor('#4CAF50')
            .borderRadius(4)
            .padding({
    left: 6, right: 6, top: 2, bottom: 2 })
            .position({
    x: movie.isImax ? 64 : 8, y: movie.isImax ? 36 : 36 })
    }
}

这段代码使用Stack组件叠加显示电影海报和各种标签,包括电影类型、IMAX和3D标签。

3.3 添加评分星星

使用星星图标替代数字评分,更加直观:

Row() {
   
    ForEach([1, 2, 3, 4, 5], (i: number) => {
   
        Image(i <= Math.floor(movie.rating / 2) ? $r("app.media.star_filled") : $r("app.media.star_outline"))
            .width(12)
            .height(12)
            .margin({
    right: 2 })
    })

    Text(movie.rating.toFixed(1))
        .fontSize(12)
        .fontColor('#FFB300')
        .margin({
    left: 4 })
}

这段代码使用ForEach循环创建5个星星图标,根据电影评分决定显示实心星星还是空心星星,并在右侧显示具体评分数字。

4. 电影详情页实现

4.1 添加状态变量和点击事件

首先,添加状态变量和点击事件处理:

@State showDetail: boolean = false;
@State currentMovie: MovieType | null = null;

// 在电影卡片上添加点击事件
Column() {
   
    // 电影卡片内容
}
.onClick(() => {
   
    this.currentMovie = movie;
    this.showDetail = true;
})

4.2 实现电影详情页

build() {
   
    Stack() {
   
        Column() {
   
            // 原有的电影网格布局
        }

        if (this.showDetail && this.currentMovie) {
   
            this.MovieDetailPage()
        }
    }
    .width('100%')
    .height('100%')
}

@Builder
private MovieDetailPage() {
   
    Column() {
   
        // 顶部导航栏
        Row() {
   
            Image($r("app.media.ic_back"))
                .width(24)
                .height(24)
                .onClick(() => {
   
                    this.showDetail = false;
                })

            Text('电影详情')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .margin({
    left: 16 })

            Blank()

            Image($r("app.media.ic_share"))
                .width(24)
                .height(24)
        }
        .width('100%')
        .height(56)
        .padding({
    left: 16, right: 16 })
        .backgroundColor(Color.White)

        // 电影详情内容
        Scroll() {
   
            Column() {
   
                // 电影海报和基本信息
                Stack() {
   
                    Image(this.currentMovie.poster)
                        .width('100%')
                        .height(240)
                        .objectFit(ImageFit.Cover)

                    Column() {
   
                        Text(this.currentMovie.title)
                            .fontSize(24)
                            .fontWeight(FontWeight.Bold)
                            .fontColor(Color.White)

                        Row() {
   
                            Text(this.currentMovie.type)
                                .fontSize(14)
                                .fontColor(Color.White)
                                .opacity(0.8)

                            Text(`评分:${
     this.currentMovie.rating.toFixed(1)}`)
                                .fontSize(14)
                                .fontColor(Color.White)
                                .opacity(0.8)
                                .margin({
    left: 16 })
                        }
                        .margin({
    top: 8 })
                    }
                    .width('100%')
                    .padding(16)
                    .alignItems(HorizontalAlign.Start)
                    .justifyContent(FlexAlign.End)
                    .backgroundImage({
   
                        source: $r("app.media.gradient_bg"),
                        repeat: ImageRepeat.NoRepeat
                    })
                }

                // 电影信息卡片
                Column() {
   
                    // 导演和主演
                    Row() {
   
                        Text('导演:')
                            .fontSize(14)
                            .fontWeight(FontWeight.Bold)
                            .fontColor('#333333')

                        Text(this.getDirector(this.currentMovie.title))
                            .fontSize(14)
                            .fontColor('#666666')
                    }
                    .width('100%')
                    .margin({
    top: 8 })

                    Row() {
   
                        Text('主演:')
                            .fontSize(14)
                            .fontWeight(FontWeight.Bold)
                            .fontColor('#333333')

                        Text(this.getActors(this.currentMovie.title))
                            .fontSize(14)
                            .fontColor('#666666')
                    }
                    .width('100%')
                    .margin({
    top: 8 })

                    // 上映日期和时长
                    Row() {
   
                        Text('上映日期:')
                            .fontSize(14)
                            .fontWeight(FontWeight.Bold)
                            .fontColor('#333333')

                        Text(this.getReleaseDate(this.currentMovie.title))
                            .fontSize(14)
                            .fontColor('#666666')
                    }
                    .width('100%')
                    .margin({
    top: 8 })

                    Row() {
   
                        Text('时长:')
                            .fontSize(14)
                            .fontWeight(FontWeight.Bold)
                            .fontColor('#333333')

                        Text(this.getDuration(this.currentMovie.title))
                            .fontSize(14)
                            .fontColor('#666666')
                    }
                    .width('100%')
                    .margin({
    top: 8 })
                }
                .width('100%')
                .padding(16)
                .backgroundColor(Color.White)
                .borderRadius(8)
                .margin({
    top: 16 })

                // 电影简介
                Column() {
   
                    Text('剧情简介')
                        .fontSize(16)
                        .fontWeight(FontWeight.Bold)
                        .fontColor('#333333')
                        .margin({
    bottom: 8 })

                    Text(this.getPlot(this.currentMovie.title))
                        .fontSize(14)
                        .fontColor('#666666')
                        .lineHeight(24)
                }
                .width('100%')
                .padding(16)
                .backgroundColor(Color.White)
                .borderRadius(8)
                .margin({
    top: 16 })

                // 场次选择
                Column() {
   
                    Text('场次选择')
                        .fontSize(16)
                        .fontWeight(FontWeight.Bold)
                        .fontColor('#333333')
                        .margin({
    bottom: 16 })

                    // 日期选择
                    Row() {
   
                        ForEach(this.getDateOptions(), (date: string, index: number) => {
   
                            Column() {
   
                                Text(date.split(' ')[0])
                                    .fontSize(14)
                                    .fontWeight(FontWeight.Medium)
                                    .fontColor(this.selectedDateIndex === index ? '#FF5722' : '#333333')

                                Text(date.split(' ')[1])
                                    .fontSize(12)
                                    .fontColor(this.selectedDateIndex === index ? '#FF5722' : '#999999')
                                    .margin({
    top: 4 })
                            }
                            .width(64)
                            .height(64)
                            .backgroundColor(this.selectedDateIndex === index ? '#FFF3E0' : Color.White)
                            .borderRadius(8)
                            .justifyContent(FlexAlign.Center)
                            .border({
   
                                width: this.selectedDateIndex === index ? 1 : 0,
                                color: '#FF5722'
                            })
                            .onClick(() => {
   
                                this.selectedDateIndex = index;
                            })
                        })
                    }
                    .width('100%')
                    .margin({
    bottom: 16 })
                    .justifyContent(FlexAlign.SpaceBetween)

                    // 场次列表
                    ForEach(this.getShowtimes(), (showtime: ShowtimeType) => {
   
                        Row() {
   
                            Column() {
   
                                Text(showtime.time)
                                    .fontSize(16)
                                    .fontWeight(FontWeight.Medium)
                                    .fontColor('#333333')

                                Text(`${
     showtime.endTime} 散场`)
                                    .fontSize(12)
                                    .fontColor('#999999')
                                    .margin({
    top: 4 })
                            }
                            .alignItems(HorizontalAlign.Start)

                            Column() {
   
                                Text(showtime.hall)
                                    .fontSize(14)
                                    .fontColor('#666666')

                                Text(showtime.language)
                                    .fontSize(12)
                                    .fontColor('#999999')
                                    .margin({
    top: 4 })
                            }
                            .alignItems(HorizontalAlign.Start)

                            Column() {
   
                                Text(${
     showtime.price}`)
                                    .fontSize(16)
                                    .fontWeight(FontWeight.Bold)
                                    .fontColor('#FF5722')

                                Text('折扣价')
                                    .fontSize(12)
                                    .fontColor('#999999')
                                    .margin({
    top: 4 })
                            }
                            .alignItems(HorizontalAlign.Start)

                            Button('购票')
                                .width(64)
                                .height(32)
                                .fontSize(14)
                                .backgroundColor('#FF5722')
                                .borderRadius(16)
                                .onClick(() => {
   
                                    this.navigateToSeatSelection(showtime);
                                })
                        }
                        .width('100%')
                        .padding(16)
                        .backgroundColor(Color.White)
                        .borderRadius(8)
                        .margin({
    bottom: 12 })
                        .justifyContent(FlexAlign.SpaceBetween)
                    })
                }
                .width('100%')
                .padding(16)
                .backgroundColor(Color.White)
                .borderRadius(8)
                .margin({
    top: 16, bottom: 16 })
            }
            .width('100%')
            .padding({
    left: 16, right: 16 })
        }
        .scrollBar(BarState.Off)
        .scrollable(ScrollDirection.Vertical)
        .width('100%')
        .layoutWeight(1)
        .backgroundColor('#F5F5F5')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
}

这段代码实现了一个完整的电影详情页,包括:

  • 顶部导航栏,带有返回按钮和分享按钮
  • 电影海报和基本信息
  • 电影详细信息,包括导演、主演、上映日期和时长
  • 剧情简介
  • 场次选择,包括日期选择和场次列表

4.3 辅助方法实现

// 获取导演信息
private getDirector(title: string): string {
   
    // 模拟数据,实际应用中应该从服务器获取
    const directors = {
   
        '流浪地球3': '郭帆',
        '长安三万里': '史涓生',
        '消失的她': '陈思诚',
        '封神第一部': '乌尔善'
    };
    return directors[title] || '未知';
}

// 获取主演信息
private getActors(title: string): string {
   
    // 模拟数据,实际应用中应该从服务器获取
    const actors = {
   
        '流浪地球3': '吴京、刘德华、李雪健、沙溢',
        '长安三万里': '周深、王凯、江疏影、杨玏',
        '消失的她': '朱一龙、倪妮、文咏珊、杜江',
        '封神第一部': '费翔、李雪健、黄渤、于适'
    };
    return actors[title] || '未知';
}

// 获取上映日期
private getReleaseDate(title: string): string {
   
    // 模拟数据,实际应用中应该从服务器获取
    const dates = {
   
        '流浪地球3': '2023-01-22',
        '长安三万里': '2023-07-08',
        '消失的她': '2023-06-22',
        '封神第一部': '2023-07-20'
    };
    return dates[title] || '未知';
}

// 获取电影时长
private getDuration(title: string): string {
   
    // 模拟数据,实际应用中应该从服务器获取
    const durations = {
   
        '流浪地球3': '173分钟',
        '长安三万里': '148分钟',
        '消失的她': '128分钟',
        '封神第一部': '148分钟'
    };
    return durations[title] || '未知';
}

// 获取剧情简介
private getPlot(title: string): string {
   
    // 模拟数据,实际应用中应该从服务器获取
    const plots = {
   
        '流浪地球3': '太阳即将毁灭,人类在地球表面建造出巨大的推进器,寻找新家园。然而宇宙之路危机四伏,为了拯救地球,流浪地球时代的年轻人再次挺身而出,展开争分夺秒的生死之战。',
        '长安三万里': '盛唐时期,高适与李白、杜甫相继结识,并在人生际遇上交错变化。安史之乱爆发,高适与李白、杜甫分处不同地点,面对家国破碎,诗人们选择了不同的抗争方式,最终为时代留下了不朽诗篇。',
        '消失的她': '何非拒绝与妻子一起过结婚纪念日,妻子离家出走,何非报警寻人,发现妻子早已消失不见。为查找真相,何非踏上寻找之路,发现一切并不简单。',
        '封神第一部': '商王殷寿与妲己相恋,却遭到女娲阻止。妲己为复仇,与九尾狐合体,蛊惑殷寿,使其成为昏君。姬昌带领周国崛起,殷寿派纣军讨伐西岐,阐教与截教弟子介入人间纷争,封神大战一触即发。'
    };
    return plots[title] || '暂无简介';
}

// 获取日期选项
private getDateOptions(): string[] {
   
    // 模拟数据,实际应用中应该根据当前日期生成
    return [
        '今天 08/01',
        '明天 08/02',
        '周三 08/03',
        '周四 08/04',
        '周五 08/05'
    ];
}

// 场次类型定义
interface ShowtimeType {
   
    time: string;      // 开始时间
    endTime: string;   // 结束时间
    hall: string;      // 影厅
    language: string;  // 语言版本
    price: number;     // 价格
}

// 获取场次信息
private getShowtimes(): ShowtimeType[] {
   
    // 模拟数据,实际应用中应该从服务器获取
    return [
        {
    time: '10:00', endTime: '12:30', hall: '1号厅', language: '国语2D', price: 39 },
        {
    time: '13:00', endTime: '15:30', hall: '2号厅', language: '国语IMAX', price: 59 },
        {
    time: '16:00', endTime: '18:30', hall: '3号厅', language: '国语3D', price: 49 },
        {
    time: '19:00', endTime: '21:30', hall: 'VIP厅', language: '国语2D', price: 69 }
    ];
}

// 导航到选座页面
private navigateToSeatSelection(showtime: ShowtimeType): void {
   
    // 实际应用中应该跳转到选座页面
    AlertDialog.show({
   
        title: '选座购票',
        message: `您选择了${
     this.currentMovie.title}${
     showtime.time}的场次,价格为¥${
     showtime.price}`,
        primaryButton: {
   
            value: '确定',
            action: () => {
   
                console.info('用户确认购票');
            }
        },
        secondaryButton: {
   
            value: '取消',
            action: () => {
   
                console.info('用户取消购票');
            }
        }
    });
}

这些辅助方法提供了电影详情页所需的各种数据,包括导演、主演、上映日期、时长、剧情简介、日期选项和场次信息。在实际应用中,这些数据应该从服务器获取。

5. 电影筛选和排序功能

5.1 添加筛选选项

// 筛选选项状态变量
@State filterOptions: {
   
    types: string[];
    minRating: number;
    sortBy: string;
} = {
   
    types: [],
    minRating: 0,
    sortBy: 'default'
};

// 筛选面板构建器
@Builder
private FilterPanel() {
   
    Column() {
   
        // 标题
        Row() {
   
            Text('筛选')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)

            Blank()

            Button('重置')
                .backgroundColor('transparent')
                .fontColor('#666666')
                .fontSize(14)
                .onClick(() => {
   
                    this.resetFilter();
                })
        }
        .width('100%')
        .padding({
    top: 16, bottom: 16 })

        // 电影类型筛选
        Text('电影类型')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .margin({
    bottom: 12 })

        Flex({
    wrap: FlexWrap.Wrap }) {
   
            ForEach(['科幻', '动画', '悬疑', '奇幻', '喜剧', '动作', '爱情', '恐怖'], (type: string) => {
   
                Text(type)
                    .fontSize(14)
                    .fontColor(this.filterOptions.types.includes(type) ? Color.White : '#666666')
                    .backgroundColor(this.filterOptions.types.includes(type) ? '#FF5722' : '#F5F5F5')
                    .borderRadius(16)
                    .padding({
    left: 12, right: 12, top: 6, bottom: 6 })
                    .margin({
    right: 8, bottom: 8 })
                    .onClick(() => {
   
                        if (this.filterOptions.types.includes(type)) {
   
                            this.filterOptions.types = this.filterOptions.types.filter(t => t !== type);
                        } else {
   
                            this.filterOptions.types.push(type);
                        }
                    })
            })
        }
        .margin({
    bottom: 16 })

        // 最低评分筛选
        Text('最低评分')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .margin({
    bottom: 12 })

        Row() {
   
            Slider({
   
                min: 0,
                max: 10,
                step: 0.5,
                value: this.filterOptions.minRating
            })
                .blockColor('#FF5722')
                .trackColor('#E0E0E0')
                .selectedColor('#FF9800')
                .showSteps(true)
                .showTips(true)
                .onChange((value: number) => {
   
                    this.filterOptions.minRating = value;
                })
                .layoutWeight(1)

            Text(this.filterOptions.minRating.toFixed(1))
                .fontSize(16)
                .fontColor('#FF5722')
                .margin({
    left: 16 })
        }
        .width('100%')
        .margin({
    bottom: 16 })

        // 排序方式
        Text('排序方式')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .margin({
    bottom: 12 })

        Column() {
   
            this.SortOption('默认排序', 'default')
            this.SortOption('评分从高到低', 'rating-desc')
            this.SortOption('评分从低到高', 'rating-asc')
        }
        .margin({
    bottom: 16 })

        // 底部按钮
        Row() {
   
            Button('取消')
                .width('48%')
                .height(40)
                .backgroundColor('#F5F5F5')
                .fontColor('#666666')
                .borderRadius(20)
                .onClick(() => {
   
                    this.showFilter = false;
                })

            Button('确定')
                .width('48%')
                .height(40)
                .backgroundColor('#FF5722')
                .fontColor(Color.White)
                .borderRadius(20)
                .onClick(() => {
   
                    this.applyFilter();
                    this.showFilter = false;
                })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius({
    topLeft: 16, topRight: 16 })
}

// 排序选项构建器
@Builder
private SortOption(text: string, value: string) {
   
    Row() {
   
        Text(text)
            .fontSize(14)
            .fontColor('#666666')

        Blank()

        Radio({
    value: value, group: 'sortBy' })
            .checked(this.filterOptions.sortBy === value)
            .onChange((isChecked: boolean) => {
   
                if (isChecked) {
   
                    this.filterOptions.sortBy = value;
                }
            })
    }
    .width('100%')
    .height(40)
    .padding({
    left: 8, right: 8 })
    .borderRadius(4)
    .backgroundColor(this.filterOptions.sortBy === value ? '#FFF3E0' : 'transparent')
    .margin({
    bottom: 8 })
}

// 重置筛选选项
private resetFilter(): void {
   
    this.filterOptions = {
   
        types: [],
        minRating: 0,
        sortBy: 'default'
    };
}

// 应用筛选
private applyFilter(): void {
   
    // 筛选逻辑在getFilteredMovies方法中实现
}

// 获取筛选后的电影列表
private getFilteredMovies(): MovieType[] {
   
    let filtered = this.movies;

    // 按类型筛选
    if (this.filterOptions.types.length > 0) {
   
        filtered = filtered.filter(movie => this.filterOptions.types.includes(movie.type));
    }

    // 按评分筛选
    if (this.filterOptions.minRating > 0) {
   
        filtered = filtered.filter(movie => movie.rating >= this.filterOptions.minRating);
    }

    // 排序
    switch (this.filterOptions.sortBy) {
   
        case 'rating-desc':
            filtered.sort((a, b) => b.rating - a.rating);
            break;
        case 'rating-asc':
            filtered.sort((a, b) => a.rating - b.rating);
            break;
        default:
            // 默认排序,保持原顺序
            break;
    }

    return filtered;
}

这段代码实现了电影筛选和排序功能,包括:

  • 电影类型筛选:用户可以选择一个或多个电影类型
  • 最低评分筛选:用户可以设置最低评分要求
  • 排序方式:用户可以选择默认排序、评分从高到低或评分从低到高

6. 购票流程设计

6.1 选座页面实现

@Builder
private SeatSelectionPage() {
   
    Column() {
   
        // 顶部导航栏
        Row() {
   
            Image($r("app.media.ic_back"))
                .width(24)
                .height(24)
                .onClick(() => {
   
                    this.showSeatSelection = false;
                })

            Text('选择座位')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .margin({
    left: 16 })

            Blank()
        }
        .width('100%')
        .height(56)
        .padding({
    left: 16, right: 16 })
        .backgroundColor(Color.White)

        // 电影和场次信息
        Row() {
   
            Column() {
   
                Text(this.currentMovie.title)
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#333333')

                Text(`${
     this.selectedShowtime.time} | ${
     this.selectedShowtime.hall} | ${
     this.selectedShowtime.language}`)
                    .fontSize(14)
                    .fontColor('#666666')
                    .margin({
    top: 4 })
            }
            .alignItems(HorizontalAlign.Start)

            Text(${
     this.selectedShowtime.price}/张`)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF5722')
        }
        .width('100%')
        .padding(16)
        .justifyContent(FlexAlign.SpaceBetween)
        .backgroundColor(Color.White)

        // 银幕提示
        Column() {
   
            Text('银幕')
                .fontSize(14)
                .fontColor('#999999')

            Image($r("app.media.ic_screen"))
                .width('80%')
                .height(24)
                .margin({
    top: 8 })
        }
        .width('100%')
        .padding({
    top: 24, bottom: 24 })
        .backgroundColor('#F5F5F5')

        // 座位图
        Grid() {
   
            ForEach(this.seats, (row: SeatRow, rowIndex: number) => {
   
                ForEach(row.seats, (seat: Seat, colIndex: number) => {
   
                    GridItem() {
   
                        if (seat.type === 'empty') {
   
                            // 空位置,不显示任何内容
                        } else {
   
                            Image(this.getSeatImage(seat))
                                .width(24)
                                .height(24)
                                .onClick(() => {
   
                                    if (seat.type === 'available') {
   
                                        this.toggleSeatSelection(rowIndex, colIndex);
                                    }
                                })
                        }
                    }
                })
            })
        }
        .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
        .rowsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
        .columnsGap(8)
        .rowsGap(8)
        .width('100%')
        .height(320)
        .padding(16)
        .backgroundColor('#F5F5F5')

        // 座位图例
        Row() {
   
            this.SeatLegend($r("app.media.seat_available"), '可选')
            this.SeatLegend($r("app.media.seat_selected"), '已选')
            this.SeatLegend($r("app.media.seat_sold"), '已售')
            this.SeatLegend($r("app.media.seat_disabled"), '不可选')
        }
        .width('100%')
        .padding(16)
        .justifyContent(FlexAlign.SpaceAround)
        .backgroundColor('#F5F5F5')

        // 已选座位和价格
        Row() {
   
            if (this.selectedSeats.length > 0) {
   
                Text(`已选${
     this.selectedSeats.length}个座位:${
     this.getSelectedSeatsText()}`)
                    .fontSize(14)
                    .fontColor('#666666')
                    .maxLines(1)
                    .textOverflow({
    overflow: TextOverflow.Ellipsis })
            } else {
   
                Text('请选择座位')
                    .fontSize(14)
                    .fontColor('#666666')
            }

            Blank()

            Text(${
     this.selectedSeats.length * this.selectedShowtime.price}`)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF5722')
        }
        .width('100%')
        .padding(16)
        .backgroundColor(Color.White)

        // 底部按钮
        Button('确认选座')
            .width('90%')
            .height(48)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .backgroundColor(this.selectedSeats.length > 0 ? '#FF5722' : '#CCCCCC')
            .borderRadius(24)
            .margin({
    top: 16, bottom: 16 })
            .enabled(this.selectedSeats.length > 0)
            .onClick(() => {
   
                this.confirmSeatSelection();
            })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
}

// 座位图例构建器
@Builder
private SeatLegend(icon: Resource, text: string) {
   
    Row() {
   
        Image(icon)
            .width(16)
            .height(16)

        Text(text)
            .fontSize(12)
            .fontColor('#666666')
            .margin({
    left: 4 })
    }
}

// 座位类型定义
interface Seat {
   
    type: 'available' | 'selected' | 'sold' | 'disabled' | 'empty';
    row: number;
    col: number;
}

// 座位行定义
interface SeatRow {
   
    seats: Seat[];
}

// 获取座位图片
private getSeatImage(seat: Seat): Resource {
   
    switch (seat.type) {
   
        case 'available':
            return $r("app.media.seat_available");
        case 'selected':
            return $r("app.media.seat_selected");
        case 'sold':
            return $r("app.media.seat_sold");
        case 'disabled':
            return $r("app.media.seat_disabled");
        default:
            return $r("app.media.seat_available");
    }
}

// 切换座位选择状态
private toggleSeatSelection(rowIndex: number, colIndex: number): void {
   
    const seat = this.seats[rowIndex].seats[colIndex];

    if (seat.type === 'available') {
   
        // 如果已选座位数量达到上限,提示用户
        if (this.selectedSeats.length >= 4 && !this.selectedSeats.some(s => s.row === rowIndex && s.col === colIndex)) {
   
            AlertDialog.show({
   
                title: '提示',
                message: '最多只能选择4个座位',
                confirm: {
   
                    value: '确定',
                    action: () => {
   
                        console.info('用户确认');
                    }
                }
            });
            return;
        }

        // 切换座位状态
        if (this.selectedSeats.some(s => s.row === rowIndex && s.col === colIndex)) {
   
            // 取消选择
            this.selectedSeats = this.selectedSeats.filter(s => !(s.row === rowIndex && s.col === colIndex));
            this.seats[rowIndex].seats[colIndex].type = 'available';
        } else {
   
            // 选择座位
            this.selectedSeats.push({
    row: rowIndex, col: colIndex });
            this.seats[rowIndex].seats[colIndex].type = 'selected';
        }
    }
}

// 获取已选座位文本
private getSelectedSeatsText(): string {
   
    return this.selectedSeats.map(seat => `${
     seat.row + 1}${
     seat.col + 1}座`).join(', ');
}

// 确认选座
private confirmSeatSelection(): void {
   
    // 实际应用中应该跳转到支付页面
    AlertDialog.show({
   
        title: '确认订单',
        message: `电影:${
     this.currentMovie.title}\n场次:${
     this.selectedShowtime.time}\n座位:${
     this.getSelectedSeatsText()}\n总价:¥${
     this.selectedSeats.length * this.selectedShowtime.price}`,
        primaryButton: {
   
            value: '确认支付',
            action: () => {
   
                this.navigateToPayment();
            }
        },
        secondaryButton: {
   
            value: '取消',
            action: () => {
   
                console.info('用户取消支付');
            }
        }
    });
}

// 导航到支付页面
private navigateToPayment(): void {
   
    // 实际应用中应该跳转到支付页面
    AlertDialog.show({
   
        title: '支付成功',
        message: '您的电影票已购买成功,请在"我的订单"中查看详情。',
        confirm: {
   
            value: '确定',
            action: () => {
   
                this.showSeatSelection = false;
                this.showDetail = false;
            }
        }
    });
}

这段代码实现了电影选座页面,包括:

  • 顶部导航栏和电影场次信息
  • 银幕提示
  • 座位图,使用Grid组件实现
  • 座位图例,显示不同状态的座位
  • 已选座位和价格信息
  • 确认选座按钮

7. 完整代码

由于完整代码较长,这里只展示了部分关键代码。完整代码包含了本教程中介绍的所有功能,包括响应式布局设计、电影卡片优化、电影详情页实现、电影筛选和排序功能、购票流程设计等。

8. 总结

本教程详细讲解了如何优化电影票务网格布局,添加交互功能,以及实现更多高级特性。通过使用HarmonyOS NEXT的GridRow和GridCol组件的高级特性,我们实现了响应式布局,使应用能够适应不同屏幕尺寸的设备。同时,我们还添加了电影卡片优化、电影详情页、电影筛选和排序功能、购票流程设计等功能,打造了一个功能完善的电影票务应用。

相关文章
|
24天前
|
开发者 UED
HarmonyOS Next快速入门:通用属性
本教程以《HarmonyOS Next快速入门》为基础,涵盖应用开发核心技能。通过代码实例讲解尺寸、位置、布局约束、Flex布局、边框、背景及图像效果等属性设置方法。如`.width()`调整宽度,`.align()`设定对齐方式,`.border()`配置边框样式,以及模糊、阴影等视觉效果的实现。结合实际案例,帮助开发者掌握HarmonyOS组件属性的灵活运用,提升开发效率与用户体验。适合初学者及进阶开发者学习。
64 0
|
24天前
|
开发者
HarmonyOS Next快速入门:通用事件
本教程聚焦HarmonyOS应用开发,涵盖事件处理的核心内容。包括事件分发、触屏事件、键鼠事件、焦点事件及拖拽事件等。通过代码实例讲解点击事件、触控事件(Down/Move/Up)、获焦与失焦事件的处理逻辑,以及气泡弹窗的应用。适合开发者快速掌握HarmonyOS Next中通用事件的使用方法,提升应用交互体验。
63 0
|
24天前
|
开发者 容器
HarmonyOS Next快速入门:Button组件
本教程摘自《HarmonyOS Next快速入门》,聚焦HarmonyOS应用开发中的Button组件。Button支持胶囊、圆形和普通三种类型,可通过子组件实现复杂功能,如嵌入图片或文字。支持自定义样式(边框弧度、文本样式、背景色等)及点击事件处理。示例代码展示了不同类型按钮的创建与交互逻辑,助开发者快速上手。适合HarmonyOS初学者及对UI组件感兴趣的开发者学习。
68 0
|
29天前
|
开发者
鸿蒙开发:资讯项目实战之项目初始化搭建
目前来说,我们的资讯项目只是往前迈了很小的一步,仅仅实现了项目创建,步虽小,但概念性的知识很多,这也是这个项目的初衷,让大家不仅仅可以掌握日常的技术开发,也能让大家理解实际的项目开发知识。
鸿蒙开发:资讯项目实战之项目初始化搭建
|
23天前
|
缓存 JavaScript IDE
鸿蒙开发:基于最新API,如何实现组件化运行
手动只是让大家了解切换的原理,在实际开发中,可不推荐手动,下篇文章,我们将通过脚本或者插件,快速实现组件化模块之间的切换,实现独立运行,敬请期待!
鸿蒙开发:基于最新API,如何实现组件化运行
|
1月前
|
SQL 弹性计算 数据库
鸿蒙5开发宝藏案例分享---优化应用时延问题
鸿蒙性能优化指南来了!从UI渲染到数据库操作,6大实战案例助你提升应用流畅度。布局层级优化、数据加载并发、数据库查询提速、相机资源延迟释放、手势识别灵敏调整及转场动画精调,全面覆盖性能痛点。附赠性能自检清单,帮助开发者高效定位问题,让应用运行如飞!来自华为官方文档的精华内容,建议收藏并反复研读,共同探讨更多优化技巧。
|
1月前
|
缓存
鸿蒙5开发宝藏案例分享---Swiper组件性能优化实战
本文分享了鸿蒙系统中Swiper组件的性能优化技巧,包括:1) 使用`LazyForEach`替代`ForEach`实现懒加载,显著降低内存占用;2) 通过`cachedCount`精准控制缓存数量,平衡流畅度与内存消耗;3) 利用`onAnimationStart`在抛滑时提前加载资源,提升构建效率;4) 添加`@Reusable`装饰器复用组件实例,减少创建开销。实际应用后,图库页帧率从45fps提升至58fps,效果显著。适合处理复杂列表或轮播场景,欢迎交流经验!
|
1月前
|
缓存 JavaScript 前端开发
鸿蒙5开发宝藏案例分享---Web开发优化案例分享
本文深入解读鸿蒙官方文档中的 `ArkWeb` 性能优化技巧,从预启动进程到预渲染,涵盖预下载、预连接、预取POST等八大优化策略。通过代码示例详解如何提升Web页面加载速度,助你打造流畅的HarmonyOS应用体验。内容实用,按需选用,让H5页面快到飞起!
|
1月前
|
数据库
鸿蒙5开发宝藏案例分享---跨线程性能优化指南
本文深入探讨鸿蒙系统跨线程序列化性能优化,借助DevEco Profiler工具定位序列化瓶颈。通过Sendable接口改造、数据瘦身等方法,将5万本书对象的序列化耗时从260ms+降至&lt;8ms,甚至&lt;1ms。总结避坑经验,建议常态化使用Profiler检测,避免传递大对象,提升多线程开发效率。
|
缓存 数据管理 Shell
鸿蒙5开发宝藏案例分享---性能分析简介
鸿蒙开发资源大揭秘!文中整理了HarmonyOS官方提供的100+场景化案例,涵盖性能优化、UI设计、设备适配等全链路内容。重点解析三大神级案例:折叠屏悬停交互、万人列表流畅滚动和服务卡片实时刷新,附带完整代码与避坑指南。通过精准搜索、代码移植和调试技巧,高效利用这些宝藏资源,助你省时省力避开开发陷阱。更有抖音级短视频流畅度优化方案等彩蛋等待探索!