图片详情页面——DetailListPage
该页面由两个横向滚动List组件构成,两个组件之间相互联动。
import display from '@ohos.display'; //用于获取设备的显示信息
import router from '@ohos.router';
import Constants from '../common/constants/Constants';
import Logger from '../common/utils/Logger';
//定义一个枚举量,用于区分滚动状态
enum scrollTypeEnum {
STOP = 'onScrollStop',
SCROLL = 'onScroll'
};
@Entry
@Component
struct DetailListPage {
//小滚动器与大滚动器实例
private smallScroller: Scroller = new Scroller();
private bigScroller: Scroller = new Scroller();
//设备宽度和小图片宽度的状态变量
@State deviceWidth: number = Constants.DEFAULT_WIDTH;
@State smallImgWidth: number = (this.deviceWidth - Constants.LIST_ITEM_SPACE * (Constants.SHOW_COUNT - 1)) /
Constants.SHOW_COUNT;
@State imageWidth: number = this.deviceWidth + this.smallImgWidth;
//从上一页面中传入的照片数组和选中索引
private photoArr: Array<Resource | string> =
(router.getParams() as Record<string, Array<Resource | string>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`];
//填充空文件,使得第一个小图片和最后一个小图片在中间位置显示
private smallPhotoArr: Array<Resource | string> = [
...Constants.CACHE_IMG_LIST,
...(router.getParams() as Record<string, Array<Resource | string>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`],
...Constants.CACHE_IMG_LIST
];
//selectedIndex与存储链接,以便在不同页面访问和保存状态。
@StorageLink('selectedIndex') selectedIndex: number = 0;
@Builder
SmallImgItemBuilder(img: Resource, index: number) {
if (index > (Constants.CACHE_IMG_SIZE - 1) && index < (this.smallPhotoArr.length - Constants.CACHE_IMG_SIZE)) {
Image(img)
.onClick(() => this.smallImgClickAction(index))
}
}
aboutToAppear() {
//获取设备宽度并更新状态变量。
let displayClass: display.Display = display.getDefaultDisplaySync();
let width = displayClass?.width / displayClass.densityPixels ?? Constants.DEFAULT_WIDTH;
this.deviceWidth = width;
this.smallImgWidth = (width - Constants.LIST_ITEM_SPACE * (Constants.SHOW_COUNT - 1)) / Constants.SHOW_COUNT;
this.imageWidth = this.deviceWidth + this.smallImgWidth;
}
onPageShow() {
//在页面显示时滚动到选中的索引位置(根据选中图片位置滚动)
this.smallScroller.scrollToIndex(this.selectedIndex);
this.bigScroller.scrollToIndex(this.selectedIndex);
}
//导航到详情页(大图)
goDetailPage() {
router.pushUrl({
url: Constants.URL_DETAIL_PAGE,
params: { photoArr: this.photoArr }
}).catch((error: Error) => {
Logger.error(Constants.URL_DETAIL_LIST_PAGE, JSON.stringify(error));
});
}
//以下方法用来处理两个List组件之间的联动
smallImgClickAction(index: number) {
//点击小图片时更新选中索引并滚动到相应位置
this.selectedIndex = index - Constants.CACHE_IMG_SIZE;
this.smallScroller.scrollToIndex(this.selectedIndex);
this.bigScroller.scrollToIndex(this.selectedIndex);
}
//根据小滚动器的滚动处理选中索引和大滚动器的滚动
smallScrollAction(type: scrollTypeEnum) {
this.selectedIndex = Math.round((this.smallScroller.currentOffset().xOffset +
this.smallImgWidth / Constants.DOUBLE_NUMBER) / (this.smallImgWidth + Constants.LIST_ITEM_SPACE));
//通过计算图片宽度和总宽度的计算值推出当前在哪个图片上
if (type === scrollTypeEnum.SCROLL) {
this.bigScroller.scrollTo({ xOffset: this.selectedIndex * this.imageWidth, yOffset: 0 });
} else {
this.smallScroller.scrollTo({ xOffset: this.selectedIndex * this.smallImgWidth, yOffset: 0 });
}
}
//根据大滚动器的滚动处理选中索引和小滚动器的滚动
bigScrollAction(type: scrollTypeEnum) {
let smallWidth = this.smallImgWidth + Constants.LIST_ITEM_SPACE;
this.selectedIndex = Math.round((this.bigScroller.currentOffset().xOffset +
smallWidth / Constants.DOUBLE_NUMBER) / this.imageWidth);
if (type === scrollTypeEnum.SCROLL) {
this.smallScroller.scrollTo({ xOffset: this.selectedIndex * smallWidth, yOffset: 0 });
} else {
//若停止滚动,则自动锁定到对应索引的位置(防止图片只露出一半)
this.bigScroller.scrollTo({ xOffset: this.selectedIndex * this.imageWidth, yOffset: 0 });
}
}
build() {
Navigation() {
//底部堆叠,小图组件正好叠在页面下方
Stack({ alignContent: Alignment.Bottom }) {
//大图片滚动组件
List({ scroller: this.bigScroller, initialIndex: this.selectedIndex }) {
ForEach(this.photoArr, (img: Resource) => {
ListItem() {
Image(img)
.height(Constants.FULL_PERCENT)
.width(Constants.FULL_PERCENT)
.objectFit(ImageFit.Contain)
.gesture(PinchGesture({ fingers: Constants.DOUBLE_NUMBER })
.onActionStart(() => this.goDetailPage()))
//捏合手势(两根手指),触发进入图片详情页
.onClick(() => this.goDetailPage())
}
.padding({
left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
right: this.smallImgWidth / Constants.DOUBLE_NUMBER
})
.width(this.imageWidth)
}, (item: Resource) => JSON.stringify(item))
}
//监听滚动容器
.onScroll((scrollOffset, scrollState) => {
if (scrollState === ScrollState.Fling) //用户手指离开屏幕后,滚动容器由于惯性继续滚动{
this.bigScrollAction(scrollTypeEnum.SCROLL);
//大滚动器联动索引和小滚动器
}
})
.onScrollStop(() => this.bigScrollAction(scrollTypeEnum.STOP))
.width(Constants.FULL_PERCENT)
.height(Constants.FULL_PERCENT)
.padding({ bottom: this.smallImgWidth * Constants.DOUBLE_NUMBER })
.listDirection(Axis.Horizontal)
//小图片滚动组件
List({
scroller: this.smallScroller,
space: Constants.LIST_ITEM_SPACE,
initialIndex: this.selectedIndex
}) {
ForEach(this.smallPhotoArr, (img: Resource, index: number) => {
ListItem() {
this.SmallImgItemBuilder(img, index)
}
.width(this.smallImgWidth)
.aspectRatio(1)
}, (item: Resource, index: number) => JSON.stringify(item) + index)
}
.listDirection(Axis.Horizontal)
.onScroll((scrollOffset, scrollState) => {
if (scrollState === ScrollState.Fling) {
this.smallScrollAction(scrollTypeEnum.SCROLL);
}
})
.onScrollStop(() => this.smallScrollAction(scrollTypeEnum.STOP))
.margin({ top: $r('app.float.detail_list_margin'), bottom: $r('app.float.detail_list_margin') })
.height(this.smallImgWidth)
.width(Constants.FULL_PERCENT)
}
.width(this.imageWidth)
.height(Constants.FULL_PERCENT)
}
.title(Constants.PAGE_TITLE)
.hideBackButton(false)
.titleMode(NavigationTitleMode.Mini)
}
}
该页面的构建比较复杂,这里就选几个值的注意的地方说说
两个List组件间和两个大小滚动器的联动
该页面实现了滑动其中任意一个组件,另一个也会跟着移动。诀窍就在设定了一个图片选择的索引。
//根据大滚动器的滚动处理选中索引和小滚动器的滚动
bigScrollAction(type: scrollTypeEnum) {
let smallWidth = this.smallImgWidth + Constants.LIST_ITEM_SPACE;
this.selectedIndex = Math.round((this.bigScroller.currentOffset().xOffset +
smallWidth / Constants.DOUBLE_NUMBER) / this.imageWidth);
if (type === scrollTypeEnum.SCROLL) {
this.smallScroller.scrollTo({ xOffset: this.selectedIndex * smallWidth, yOffset: 0 });
} else {
//若停止滚动,则自动锁定到对应索引的位置(防止图片只露出一半)
this.bigScroller.scrollTo({ xOffset: this.selectedIndex * this.imageWidth, yOffset: 0 });
}
}
就以这个方法举例。在大滚动器滚动时获取其滚动偏移量,然后再经过计算推导出所在图片的索引值。在获取到索引值后,通过索引值*宽度锁定小滚动器所在位置并滚动到该位置。滚动结束后,大滚动器自动锁定当前索引对应位置进行滚动校准。
空占位实现首位末位图片居中显示
空占位即我们在Constants.ets中设立的CACHE_IMG_LIST
static readonly CACHE_IMG_LIST: string[] = ['', '', '', '']
//在定义小图片数组时这样定义
private smallPhotoArr: Array<Resource | string> = [
...Constants.CACHE_IMG_LIST,
...(router.getParams() as Record<string, Array<Resource | string>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`],
...Constants.CACHE_IMG_LIST
];
效果就是令首位末位图片在中间显示
在本页面中通过selectedIndex这一索引量来实现大图和小图之间的同步,虽然中间涉及到的滚动条定位有些麻烦,但是理解了就可以了()
查看大图&通过手势控制图片——DetailPage
在该页面我们可以左右滑动来切换图片,也可以进行手势操作实现图片的移动和缩放放大
import display from '@ohos.display'
import router from '@ohos.router';
import Constants from '../common/constants/Constants'
@Entry
@Component
struct DetailPage {
scroller: Scroller = new Scroller()
photoArr: Array<Resource> = (router.getParams() as Record<string,Array<Resource>>)[`${Constants.PARAM_PHOTO_ARR_KEY}`]
//记录手势操作前的偏移量
preOffsetX: number = 0
preOffsetY: number = 0
//记录当前图片的缩放比例
currentScale: number = 1
@State
deviceWidth: number = Constants.DEFAULT_WIDTH
@State
smallImgWidth: number = (Constants.DEFAULT_WIDTH - Constants.LIST_ITEM_SPACE * (Constants.SHOW_COUNT - 1)) / Constants.SHOW_COUNT
@State
imageWidth: number = this.deviceWidth + this.smallImgWidth
@StorageLink
('selectedIndex')selectedIndex: number = 0
@State
//是否进行缩放操作
isScalling: boolean = true
@State
imgScale: number = 1 //缩放比例
@State
imgOffSetX: number = 0 //偏移量
@State
imgOffSetY: number = 0
@State
bgOpacity: number = 0 //控制背景透明度
aboutToAppear(): void {
let displayClass: display.Display = display.getDefaultDisplaySync()
let width = displayClass?.width / displayClass.densityPixels ?? Constants.DEFAULT_WIDTH
this.deviceWidth = width
this.smallImgWidth = (width - Constants.LIST_ITEM_SPACE * (Constants.SHOW_COUNT - 1) / Constants.SHOW_COUNT)
this.imageWidth = this.deviceWidth + this.smallImgWidth
}
//重置图片的缩放比例
resetImg() {
this.imgScale = 1
this.currentScale = 1
this.preOffsetX = 0
this.preOffsetY = 0
}
//根据图片的偏移量来判断是否切换图片
handlePanEnd() {
//计算初始偏移量
let initOffsetX = (this.imgScale - 1) * this.imageWidth + this.smallImgWidth
//拖动水平距离的绝对值是否超过了initOffsetX
if(Math.abs(this.imgOffSetX) > initOffsetX)
{
//不是第一张图片且向右拖动了足够长,切换上一张图片
if(this.imgOffSetX > initOffsetX && this.selectedIndex > 0)
{
this.selectedIndex = this.selectedIndex - 1
}
//不是最后一张图片且向左拖动了足够长的距离,切换下一张图片
else if(this.imgOffSetX < -initOffsetX && this.selectedIndex < (this.photoArr.length - 1))
{
this.selectedIndex = this.selectedIndex + 1
}
//重置图片状态
this.isScalling = false
this.resetImg()
//滚动到新图片的位置
this.scroller.scrollTo({
xOffset: this.selectedIndex * this.imageWidth,
yOffset: 0
})
}
}
build() {
Stack(){
//水平滚动显示图片
List({ scroller: this.scroller, initialIndex: this.selectedIndex}) {
ForEach(this.photoArr,(img: Resource)=>{
ListItem(){
Image(img)
.objectFit(ImageFit.Contain)
//点击返回列表页
.onClick(()=>{
router.back()
})
}
//缩放手势
.gesture(PinchGesture({fingers: Constants.DOUBLE_NUMBER})
.onActionStart(()=>{
this.resetImg()
this.isScalling = true //表示正在缩放图片
this.imgOffSetX = 0 //重置偏移量
this.imgOffSetY = 0
})
.onActionUpdate((event: GestureEvent) => {
//更新图片缩放比例
this.imgScale = this.currentScale * event.scale
})
.onActionEnd(() => {
//判断图片缩放比例是否小于1
if(this.imgScale < 1){
this.resetImg
this.imgOffSetX = 0
this.imgOffSetY = 0
}else {
//更新当前比例为图片最终的缩放比例
this.currentScale = this.imgScale
}
})
)
.padding({
left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
right: this.smallImgWidth / Constants.DOUBLE_NUMBER
})
.width(this.imageWidth)
},(item: Resource) => JSON.stringify(item))
}
.onScrollStop(()=>{
let currentIndex = Math.round((this.scroller.currentOffset().xOffset + (this.imageWidth / Constants.DOUBLE_NUMBER)) / this.imageWidth)
this.selectedIndex = currentIndex
this.scroller.scrollTo({
xOffset: currentIndex * this.imageWidth,
yOffset: 0
})
})
.width(Constants.FULL_PERCENT)
.height(Constants.FULL_PERCENT)
.listDirection(Axis.Horizontal)
.visibility(this.isScalling ? Visibility.Hidden : Visibility.Visible)
//设置该部分的可见性
//可缩放可平移的图片显示区域
Row() {
Image(this.photoArr[this.selectedIndex])
//设置图片位置和缩放比例
.position({
x : this.imgOffSetX,
y : this.imgOffSetY
})
.scale({
x: this.imgScale,
y: this.imgScale
})
.objectFit(ImageFit.Contain)
.onClick(()=>{
router.back()
})
}
//组合手势事件
.gesture(GestureGroup(GestureMode.Exclusive,
PinchGesture({ fingers: Constants.DOUBLE_NUMBER})
.onActionUpdate((event: GestureEvent)=>{
this.imgScale = this.currentScale * event.scale
})
.onActionEnd(()=>{
if(this.imgScale < 1) {
this.resetImg
this.imgOffSetX = 0
this.imgOffSetY = 0
} else {
this.currentScale = this.imgScale
}
}),
PanGesture()
.onActionStart(()=>{
this.preOffsetX = this.imgOffSetX
this.preOffsetY = this.imgOffSetY
})
.onActionUpdate((event: GestureEvent)=>{
this.imgOffSetX = this.preOffsetX + event.offsetX
this.imgOffSetY = this.preOffsetY + event.offsetY
})
.onActionEnd(()=>{
this.handlePanEnd()
})
))
.padding({
left: this.smallImgWidth / Constants.DOUBLE_NUMBER,
right: this.smallImgWidth / Constants.DOUBLE_NUMBER
})
.width(this.imageWidth)
.height(Constants.FULL_PERCENT)
.visibility(this.isScalling ? Visibility.Visible : Visibility.Hidden)
}
.offset({
x: -(this.smallImgWidth / Constants.DOUBLE_NUMBER)
})
.width(this.imageWidth)
.height(Constants.FULL_PERCENT)
.backgroundColor($r('app.color.detail_background'))
}
}
在手势缩放事件中经历了三个阶段
.onActionStart(() => { ... })://当捏合手势开始时,会触发这个回调函数。
.onActionUpdate((event: GestureEvent) => { ... }): //当捏合手势发生变化时,会触发这个回调函数。
.onActionEnd(() => { ... }): //当捏合手势结束时,会触发这个回调函数。
三个阶段的回调事件各司其职,完成了图片的缩放及完成缩放后的图片比例
在一开始设定了是否缩放选项为true,保证了Row组件控制的图片可见,在滑动图片时又更新为false,此时Row组件不可见,List组件显示,进而可以实现图片的切换操作。而在进入缩放事件中,Row组件又再次可见,可以实现图片的缩放移动操作。通过使用一个boolean值实现了Row组件和List组件之间的切换显示。两个组件各自完成了自己的工作。
组合手势GestureGroup
//组合手势
.gesture(GestureGroup(GestureMode.Exclusive,
PinchGesture({ fingers: Constants.DOUBLE_NUMBER})
.onActionUpdate((event: GestureEvent)=>{
this.imgScale = this.currentScale * event.scale
})
.onActionEnd(()=>{
if(this.imgScale < 1) {
this.resetImg
this.imgOffSetX = 0
this.imgOffSetY = 0
} else {
this.currentScale = this.imgScale
}
}),
PanGesture()
.onActionStart(()=>{
this.preOffsetX = this.imgOffSetX
this.preOffsetY = this.imgOffSetY
})
.onActionUpdate((event: GestureEvent)=>{
this.imgOffSetX = this.preOffsetX + event.offsetX
this.imgOffSetY = this.preOffsetY + event.offsetY
})
.onActionEnd(()=>{
this.handlePanEnd()
})
))
//基本格式
.gesture(GestureGroup(GestureMode. ... , ... , ...)
其中GestureMode(组合手势类型)分为
-
顺序识别(Sequence):按照手势的注册顺序识别手势,直到所有的手势识别成功,如果其中一个手势识别失败,则所有手势识别失败。
-
并行识别(Parallel):将注册的手势同时进行识别,直到所有手势识别结束。该方法下的的手势进行识别时互不影响。允许系统同时检测和识别多个手势,提高了交互效率。
-
互斥识别(Exclusive):同一时间只能有一个手势被识别。
在本项目中,捏合手势和平移手势为互斥识别组合手势。即同时只能识别二者之一。这样便可以防止缩放和平移图片两个动作同时进行。
简单总结一下:
在后边两个页面的构建中涉及到了坐标一类的数据,比如设定大小滚动器同步滚动,图片平移定位等。其中有些还要进行运算才能确定最终坐标的值。(比较麻烦
手势也是用户交互中重要的一步,在处理图片缩放移动命令时运用了单手势和组合手势,手势之间的逻辑关系在以后的开发中要多加注意。
下一次开发就要做自己的项目了()