Uniapp之自定义图片预览

场景

在一些场景,uniapp 的原生图片预览无法满足要求,比如自定义图片预览元素。另外原生的图片预览无法阻止截屏问题。

示例

html 复制代码
<template>
  <preview-img-popup ref="preImgPopup" img-field="imageUrl" @menu="onPreImgMenu"></preview-img-popup>
</template>
<script>
export default {
  data() {
    return {
      allDownload: false
    }
  },
  onBackPress() {
    // 返回时判断是否关闭图片预览,否则关闭并阻止返回
    if (this.$refs.preImgPopup.isOpen()) {
      this.$refs.preImgPopup.close();
      return true;
    }
    return false;
  },
  methods: {
    previewImage(url, dataList) {
      this.$refs.preImgPopup.open(dataList, url)
    },
    onPreImgMenu(data) {
      const _this = this;
      const allDownload = this.allDownload;
      uni.showActionSheet({
        itemList: [allDownload ? '保存图片' : '作者禁止下载'],
        itemColor: allDownload ? '#000' : 'rgb(243,140,140)',
        success: (res) => {
          if (!allDownload) {
            uni.showToast({
              title: '作者禁止下载',
              icon: "none",
            })
            return
          }
          if (res.tapIndex === 0) {
            _this.$refs.preImgPopup.save(data.imageUrl).then((res) => {
              console.log(res)
              uni.showToast({
                title: '图片已保存到相册',
                icon: "none",
              })
            }).catch((err) => {
              console.error('图片保存失败', err);
            })
          }
        }
      })
    }
  }
}
</script>

源码

当前实现有几个缺点:

  1. 拖拽图片不流畅,movable-area 与 swiper 会有冲突
  2. 长图放大后可能无法拖拽查看不全

暂时没有优化,如果不需要放大后能够切换 swiper,可是使用下面的 简化的源码,相对来说要流畅一些

html 复制代码
<script>
export default {
  name: 'PreviewImgPopup',
  props: {
    // 图片字段,当传入urls为对象数组时需要指定
    imgField: {
      type: String
    },
    // 是否显示右上角的菜单
    showMenu: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      urls: [],
      currentIndex: 0,
      showVal: false,
      // 每张图片独立的缩放状态
      imageStates: [],
      minScale: 1,
      maxScale: 3,
      // swiper控制
      swiperDisabled: false,
      // movable-view的尺寸
      movableWidth: 100,
      movableHeight: 100,
      scaleValue: 1
    }
  },
  computed: {
    preNum() {
      return `${this.currentIndex + 1}/${this.urls.length}`
    },
    // 当前图片的状态
    currentImageState() {
      return this.imageStates[this.currentIndex] || {scale: 1, translateX: 0, translateY: 0}
    },
    // 当前图片的缩放比例
    scale() {
      return this.currentImageState.scale
    },
    // 当前图片的X位移
    translateX() {
      return this.currentImageState.translateX
    },
    // 当前图片的Y位移
    translateY() {
      return this.currentImageState.translateY
    }
  },
  methods: {
    open(urls, indexOrUrl = 0) {
      this.showVal = true
      this.urls = urls || []
      // 初始化每张图片的状态
      this.imageStates = this.urls.map(() => ({
        scale: 1,
        translateX: 0,
        translateY: 0
      }))

      const maxIndex = this.urls.length - 1
      if (typeof indexOrUrl === 'string') {
        // 如果是字符串,则根据图片地址查找
        let index = -1;
        for (let i = 0; i < this.urls.length; i++) {
          const item = this.urls[i]
          if (this.imgField) {
            if (item[this.imgField] === indexOrUrl) {
              index = i
              break
            }
          } else {
            if (item === indexOrUrl) {
              index = i
              break
            }
          }
        }
        if (index !== -1) {
          this.currentIndex = index
        } else {
          this.currentIndex = 0
        }
      } else {
        this.currentIndex = indexOrUrl > maxIndex ? maxIndex : indexOrUrl
      }
    },
    isOpen() {
      return this.showVal
    },
    close() {
      this.showVal = false
    },
    onClose() {
      this.showVal = false
      this.$emit('close')
    },
    onSwiperChange(e) {
      this.currentIndex = e.detail.current
      if (this.imageStates[this.currentIndex].scale > 1) {
        this.swiperDisabled = true
      }
    },
    onImageTap() {
      this.close()
    },
    getImgUrl(data) {
      if (this.imgField) {
        return data[this.imgField]
      }
      return data
    },
    handleMenuClick() {
      this.$emit('menu', this.urls[this.currentIndex])
    },
    /**
     * 保存图片
     * @param url 图片地址
     * @return {Promise<string>}
     */
    save(url) {
      return new Promise((resolve, reject) => {
        uni.downloadFile({
          url: url,
          success(res) {
            if (res.tempFilePath) {
              uni.saveImageToPhotosAlbum({
                filePath: res.tempFilePath,
                success(re) {
                  resolve(re.path)
                },
                fail(err) {
                  reject(err)
                }
              })
            }
          },
          fail(err) {
            reject(err)
          }
        })
      })
    },
    // 重置指定图片的缩放状态
    resetScale(index = this.currentIndex) {
      if (this.imageStates[index]) {
        this.scaleValue = 1
        this.minScale = 1
        this.imageStates[index].scale = 1
        this.imageStates[index].translateX = 0
        this.imageStates[index].translateY = 0
        this.$nextTick(() => {
          this.minScale = 0.6
        })
      }
    },
    // movable-view事件处理
    onMovableChange(e, index) {
      if (index === this.currentIndex && this.imageStates[index]) {
        const x = this.imageStates[index].translateX = e.detail.x
        this.imageStates[index].translateY = e.detail.y

        const systemInfo = uni.getSystemInfoSync()
        const scale = this.imageStates[index].scale
        const winWidth = systemInfo.windowWidth
        const scleWidth = (winWidth - 1) * scale

        if (scale > 1) {
          if (x < 0) {
            if (winWidth - x >= scleWidth) {
              this.swiperDisabled = false
            } else {
              this.swiperDisabled = true
            }
          } else if (x >= 0) {
            this.swiperDisabled = false
          } else {
            this.swiperDisabled = true
          }
        }
      }
    },
    onMovableScale(e, index) {
      if (index === this.currentIndex && this.imageStates[index]) {
        const scale = this.imageStates[index].scale = e.detail.scale

        if (scale > 1) {
          this.swiperDisabled = true
        } else {
          this.swiperDisabled = false
        }

        // 缩放时的swiper控制:只有在缩放<=1时才允许swiper
        // 放大时的精确边界检测由onMovableChange处理
        if (e.detail.scale <= 1) {
          this.swiperDisabled = false
        }
        // 注意:不在这里设置 swiperDisabled = true,让onMovableChange来精确控制
      }
    },
    onTouchEnd(e, index) {
      return;
      // uni.$u.throttle(() => {
      //   if (index === this.currentIndex && this.imageStates[index]) {
      //     const scale = this.imageStates[index].scale
      //     console.log('scale', scale)
      //
      //     // 如果缩放小于1,自动恢复到默认大小
      //     if (scale < 1) {
      //       setTimeout(() => {
      //         this.resetScale(index)
      //       }, 100)
      //     }
      //   }
      // }, 500)
    }
  },
  mounted() {
    uni.setNavigationBarColor({
      frontColor: '#ffffff',
      backgroundColor: '#000'
    })
  }
}
</script>

<template>
  <u-popup :show="showVal" mode="center" @close="onClose" bgColor="#000" :safeAreaInsetBottom="false"
           closeOnClickOverlay safe-area-inset-top>
    <view class="preview-img-container">
      <!-- 顶部工具栏 -->
      <slot name="top">
        <view class="top-tools">
          <view class="pre-num">{{ preNum }}</view>
          <view class="menu">
            <u-icon name="more-dot-fill" color="#fff" size="20px" @click="handleMenuClick"></u-icon>
          </view>
        </view>
      </slot>

      <!-- 图片轮播区域 -->
      <swiper class="img-swiper" :current="currentIndex" @change="onSwiperChange" :indicator-dots="false"
              indicator-active-color="#fff" indicator-color="rgba(255, 255, 255, .3)" :autoplay="false"
              :circular="false"
              :disable-touch="swiperDisabled" :disable-programmatic-animation="swiperDisabled">
        <swiper-item v-for="(img, index) in urls" :key="index" class="swiper-item">
          <movable-area class="movable-area" :scale-area="true">
            <movable-view class="movable-view" :scale="true" direction="all" :scale-min="minScale" :scale-max="maxScale"
                          :inertia="true" :out-of-bounds="false" :damping="100" :scale-value="scaleValue"
                          :x="imageStates[index] ? imageStates[index].translateX : 0"
                          :y="imageStates[index] ? imageStates[index].translateY : 0"
                          @change="(e) => onMovableChange(e, index)"
                          @scale="(e) => onMovableScale(e, index)" @tap="onImageTap"
                          @touchend="(e) => onTouchEnd(e, index)">
              <image :src="getImgUrl(img)" class="preview-img" mode="aspectFit"/>
            </movable-view>
          </movable-area>
        </swiper-item>
      </swiper>

      <slot name="bottom"></slot>
    </view>
  </u-popup>
</template>

<style scoped lang="scss">
.preview-img-container {
  width: 100vw;
  height: 100vh;
  position: relative;
  display: flex;
  flex-direction: column;
}

.top-tools {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  padding: 60rpx 40rpx 40rpx;
  box-sizing: border-box;
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);

  .pre-num {
    color: #fff;
    font-size: 16px;
    font-weight: 500;
  }
}

.img-swiper {
  width: 100%;
  height: 100%;

  .swiper-item {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .movable-area {
    width: 100%;
    height: 100%;
  }

  .movable-view {
    width: 100%;
    height: 100%;
  }

  .preview-img {
    width: 100%;
    height: 100%;
    max-width: 100%;
    max-height: 100%;
  }
}
</style>

简化的源码

注意,当前实现图片放大后无法切换 swiper

html 复制代码
<script>
export default {
  name: 'PreviewImgPopup',
  props: {
    // 图片字段,当传入urls为对象数组时需要指定
    imgField: {
      type: String
    },
    // 是否显示右上角的菜单
    showMenu: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      urls: [],
      currentIndex: 0,
      showVal: false,
      curScale: 1,
      minScale: 1,
      maxScale: 3,
      scaleValue: 1,
      // swiper控制,用于拖拽图片时禁用swiper
      swiperDisabled: false,
      safeAreaInsetBottom: 0,
      safeAreaInsetTop: 0,
      systemInfo: {},
      // 是否swiper移动中,用于滑动swiper时禁用图片拖拽
      swiperMoving: false
    }
  },
  computed: {
    // 右上角的数字显示
    preNum() {
      return `${this.currentIndex + 1}/${this.urls.length}`
    },
  },
  methods: {
    open(urls, indexOrUrl = 0) {
      // 初始化数据
      this.currentIndex = 0
      this.curScale = 1
      this.minScale = 1
      this.maxScale = 3
      this.scaleValue = 1
      this.swiperDisabled = false
      this.swiperMoving = false

      this.showVal = true
      this.urls = urls || []

      const maxIndex = this.urls.length - 1
      if (typeof indexOrUrl === 'string') {
        // 如果是字符串,则根据图片地址查找
        let index = -1;
        for (let i = 0; i < this.urls.length; i++) {
          const item = this.urls[i]
          if (this.imgField) {
            if (item[this.imgField] === indexOrUrl) {
              index = i
              break
            }
          } else {
            if (item === indexOrUrl) {
              index = i
              break
            }
          }
        }
        if (index !== -1) {
          this.currentIndex = index
        } else {
          this.currentIndex = 0
        }
      } else {
        this.currentIndex = indexOrUrl > maxIndex ? maxIndex : indexOrUrl
      }
    },
    isOpen() {
      return this.showVal
    },
    close() {
      this.showVal = false
    },
    onClose() {
      this.showVal = false
      this.$emit('close')
    },
    onImageTap() {
      this.close()
    },
    getImgUrl(data) {
      if (this.imgField) {
        return data[this.imgField]
      }
      return data
    },
    handleMenuClick() {
      this.$emit('menu', this.urls[this.currentIndex])
    },
    /**
     * 保存图片
     * @param url 图片地址
     * @return {Promise<string>}
     */
    save(url) {
      return new Promise((resolve, reject) => {
        uni.downloadFile({
          url: url,
          success(res) {
            if (res.tempFilePath) {
              uni.saveImageToPhotosAlbum({
                filePath: res.tempFilePath,
                success(re) {
                  resolve(re.path)
                },
                fail(err) {
                  reject(err)
                }
              })
            }
          },
          fail(err) {
            reject(err)
          }
        })
      })
    },
    onMovableScale(e) {
      const scale = this.curScale = e.detail.scale
      if (scale > 1) {
        this.swiperDisabled = true
      } else {
        this.swiperDisabled = false
      }
    },
    onSwiperTran() {
      this.swiperMoving = true
    },
    onSwiperitemEnd() {
      this.swiperMoving = false
    },
  },
  mounted() {
    uni.setNavigationBarColor({
      frontColor: '#ffffff',
      backgroundColor: '#000'
    })

    const systemInfo = this.systemInfo = uni.getSystemInfoSync()
    this.safeAreaInsetTop = systemInfo.safeArea.top;
  }
}
</script>

<template>
  <u-popup :show="showVal" mode="center" @close="onClose" bgColor="#000" :safeAreaInsetBottom="false"
           closeOnClickOverlay :safe-area-inset-top="false">
    <view class="preview-img-container">
      <!-- 顶部工具栏 -->
      <slot name="top">
        <view class="top-tools">
          <view class="safeTop" :style="{height: safeAreaInsetTop + 'px'}"></view>
          <view class="top-tools-content">
            <view class="pre-num">{{ preNum }}</view>
            <view class="menu">
              <u-icon name="more-dot-fill" color="#fff" size="20px" @click="handleMenuClick"></u-icon>
            </view>
          </view>
        </view>
      </slot>

      <!-- 图片轮播区域 -->
      <swiper class="img-swiper" :current="currentIndex" :indicator-dots="false"
              indicator-active-color="#fff" indicator-color="rgba(255, 255, 255, .3)" :autoplay="false"
              :circular="false"
              :disable-touch="swiperDisabled"
              :disable-programmatic-animation="swiperDisabled"
              @transition="onSwiperTran"
              @animationfinish="onSwiperitemEnd">
        <swiper-item v-for="(img, index) in urls" :key="index" class="swiper-item">
          <movable-area class="movable-area" :scale-area="false">
            <movable-view class="movable-view" :scale="true" :direction="swiperMoving ? 'none' : 'all'"
                          :scale-min="minScale" :scale-max="maxScale"
                          :inertia="true" :out-of-bounds="false" :damping="100" :scale-value="scaleValue"
                          @scale="onMovableScale" @tap="onImageTap">
              <scroll-view v-show="!swiperDisabled && curScale === 1" :scroll-y="true"
                           class="scroll-view" :scroll-x="false">
                <view class="image-content">
                  <image :src="getImgUrl(img)" class="preview-img" mode="widthFix"/>
                </view>
              </scroll-view>
              <view v-show="!(!swiperDisabled && curScale === 1)" class="image-content">
                <image :src="getImgUrl(img)" class="preview-img" mode="widthFix"/>
              </view>
            </movable-view>
          </movable-area>
        </swiper-item>
      </swiper>

      <slot name="bottom"></slot>
    </view>
  </u-popup>
</template>

<style scoped lang="scss">
.preview-img-container {
  width: 100vw;
  height: 100vh;
  position: relative;
  display: flex;
  flex-direction: column;
}

.top-tools {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  padding: 40rpx;
  box-sizing: border-box;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);

  .top-tools-content {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .pre-num {
    color: #fff;
    font-size: 16px;
    font-weight: 500;
  }
}

.img-swiper {
  width: 100vw;
  height: 100vh;

  .swiper-item {
    width: 100vw;
    height: 100vh;
  }

  .movable-area {
    width: 100vw;
    height: 100vh;
  }

  .movable-view {
    width: 100vw;
    height: auto;
  }

  .scroll-view {
    height: 100vh;
  }

  .image-content {
    width: 100vw;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100vh;

    .preview-img {
      width: 100vw;
    }
  }
}

.safeBottom, .safeTop {
  width: 100%;
  background-color: transparent;
}
</style>
相关推荐
GISer_Jing1 小时前
JavaScript 中Object、Array 和 String的常用方法
开发语言·javascript·ecmascript
brzhang1 小时前
颠覆你对代码的认知:当程序和数据只剩下一棵树,能读懂这篇文章的人估计全球也不到 100 个人
前端·后端·架构
斟的是酒中桃2 小时前
基于Transformer的智能对话系统:FastAPI后端与Streamlit前端实现
前端·transformer·fastapi
烛阴2 小时前
Fract - Grid
前端·webgl
JiaLin_Denny2 小时前
React 实现人员列表多选、全选与取消全选功能
前端·react.js·人员列表选择·人员选择·人员多选全选·通讯录人员选择
brzhang2 小时前
我见过了太多做智能音箱做成智障音箱的例子了,今天我就来说说如何做意图识别
前端·后端·架构
为什么名字不能重复呢?3 小时前
Day1||Vue指令学习
前端·vue.js·学习
eternalless3 小时前
【原创】中后台前端架构思路 - 组件库(1)
前端·react.js·架构
Moment3 小时前
基于 Tiptap + Yjs + Hocuspocus 的富文本协同项目,期待你的参与 😍😍😍
前端·javascript·react.js
Krorainas4 小时前
HTML 页面禁止缩放功能
前端·javascript·html