兼容 ios

This commit is contained in:
icssoa
2025-09-04 20:18:18 +08:00
parent 083c8b1325
commit f01d1107b9
15 changed files with 316 additions and 146 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ npm-debug.log*
# Editor directories and files
.project
.idea
.hbuilderx
*.suo
*.ntvs*
*.njsproj

View File

@@ -6,18 +6,18 @@
</demo-item>
<demo-item :label="t('不同类型')">
<view class="flex flex-row flex-wrap mb-2">
<view class="flex flex-row flex-wrap mb-2 overflow-visible">
<cl-button type="primary">{{ t("主要") }}</cl-button>
<cl-button type="success">{{ t("成功") }}</cl-button>
<cl-button type="warn">{{ t("警告") }}</cl-button>
</view>
<view class="flex flex-row mb-2">
<view class="flex flex-row mb-2 overflow-visible">
<cl-button type="error">{{ t("危险") }}</cl-button>
<cl-button type="info">{{ t("信息") }}</cl-button>
</view>
<view class="flex flex-row">
<view class="flex flex-row overflow-visible">
<cl-button type="light">{{ t("浅色") }}</cl-button>
<cl-button type="dark">{{ t("深色") }}</cl-button>
</view>

View File

@@ -1,6 +1,12 @@
<template>
<cl-page>
<view class="p-3 overflow-visible">
<view class="p-3">
<demo-item>
<cl-text color="info">
{{ t("长按项即可拖动排序") }}
</cl-text>
</demo-item>
<demo-item :label="t('单列排序')">
<cl-draggable v-model="list">
<template #item="{ item, index }">
@@ -16,6 +22,18 @@
</cl-draggable>
</demo-item>
<demo-item :label="t('不需要长按')">
<cl-draggable v-model="list5" :long-press="false">
<template #item="{ item }">
<view
class="flex flex-row items-center p-3 bg-surface-100 rounded-lg mb-2 dark:!bg-surface-700"
>
<cl-text>{{ (item as UTSJSONObject).label }}</cl-text>
</view>
</template>
</cl-draggable>
</demo-item>
<demo-item :label="t('结合列表使用')">
<cl-list border>
<cl-draggable v-model="list2">
@@ -26,8 +44,12 @@
arrow
:pt="{
inner: {
className:
dragging && dragIndex == index ? '!bg-surface-100' : ''
className: parseClass([
[
dragging && dragIndex == index,
isDark ? '!bg-surface-700' : '!bg-surface-100'
]
])
}
}"
></cl-list-item>
@@ -75,41 +97,57 @@
import { t } from "@/locale";
import DemoItem from "../components/item.uvue";
import { ref } from "vue";
import { isDark, parseClass } from "@/cool";
// list李白《将进酒》
const list = ref<UTSJSONObject[]>([
{
label: "明月几时有,把酒问青天"
label: "君不见黄河之水天上来"
},
{
label: "不知天上宫阙,今夕是何年",
label: "奔流到海不复回",
disabled: true
},
{
label: "我欲乘风归去,又恐琼楼玉宇"
label: "君不见高堂明镜悲白发"
},
{
label: "高处不胜寒,起舞弄清影"
label: "朝如青丝暮成雪"
},
{
label: "何似在人间"
label: "人生得意须尽欢"
}
]);
// list5杜甫《春望》
const list5 = ref<UTSJSONObject[]>([
{
label: "国破山河在"
},
{
label: "城春草木深"
},
{
label: "感时花溅泪"
}
]);
// list2王之涣《登鹳雀楼》
const list2 = ref<UTSJSONObject[]>([
{
label: "明月几时有,把酒问青天"
label: "白日依山尽"
},
{
label: "不知天上宫阙,今夕是何年"
label: "黄河入海流"
},
{
label: "我欲乘风归去,又恐琼楼玉宇"
label: "欲穷千里目"
},
{
label: "高处不胜寒,起舞弄清影"
label: "更上一层楼"
},
{
label: "何似在人间"
label: "一览众山小"
}
]);

View File

@@ -19,7 +19,11 @@
<template #bottom>
<view class="py-3">
<cl-loadmore :loading="loading" v-if="list.length > 0"></cl-loadmore>
<cl-loadmore
v-if="list.length > 0"
:loading="loading"
safe-area-bottom
></cl-loadmore>
</view>
</template>
</cl-list-view>

View File

@@ -36,7 +36,7 @@
</template>
</cl-waterfall>
<cl-loadmore :loading="true"></cl-loadmore>
<cl-loadmore :loading="true" safe-area-bottom></cl-loadmore>
</view>
</cl-page>
</template>

20
types/uni-app.d.ts vendored
View File

@@ -446,6 +446,26 @@ declare interface UniElement {
success?: (res: { tempFilePath: string }) => void;
fail?: (err: { errCode: number; errMsg: string }) => void;
}): void;
getDrawableContext(): DrawableContext;
animate(
keyframes: UniAnimationKeyframe | UniAnimationKeyframe[],
options?:
| {
delay?: number;
direction?: "normal" | "reverse" | "alternate" | "alternate-reverse";
duration?: number;
easing?:
| "ease"
| "ease-in"
| "ease-out"
| "ease-in-out"
| "linear"
| "cubic-bezier";
fill?: "backwards" | "forwards" | "both" | "none";
iterations?: number;
}
| number
): { id: string; playState: "running" | "paused" | "finished" | "idle" } | null;
}
declare interface CanvasContext extends HTMLCanvasElement {

View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@ohos/svg": "2.2.1"
}
}

View File

@@ -0,0 +1,5 @@
import { BuilderNode } from "@kit.ArkUI";
export class CoolSvg {
load(src: string, color: string) {}
}

View File

@@ -10,6 +10,7 @@
>
<!-- 图片容器 - 可拖拽和缩放的图片区域 -->
<view class="cl-cropper__image">
<!-- @vue-ignore -->
<image
class="cl-cropper__image-inner"
:class="[

View File

@@ -3,28 +3,35 @@
class="cl-draggable"
:class="[
{
'cl-draggable--grid': props.columns > 1
'cl-draggable--columns': props.columns > 1
},
pt.className
]"
>
<!-- @vue-ignore -->
<view
v-for="(item, index) in list"
:key="getItemKey(item, index)"
class="cl-draggable__item"
:class="[
{
'cl-draggable__item--disabled': disabled
},
dragging && dragIndex == index ? `opacity-80 ${pt.ghost?.className}` : ''
'cl-draggable__item--disabled': disabled,
'cl-draggable__item--dragging': dragging && dragIndex == index,
'cl-draggable__item--animating': dragging && dragIndex != index
}
]"
:style="getItemStyle(index)"
@touchstart="
(event: UniTouchEvent) => {
onTouchStart(event, index);
onTouchStart(event, index, 'touch');
}
"
@touchmove.stop.prevent="onTouchMove"
@longpress="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'longpress');
}
"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<slot
@@ -44,6 +51,7 @@
import { computed, ref, getCurrentInstance, type PropType, watch } from "vue";
import { isNull, parsePt, uuid } from "@/cool";
import type { PassThroughProps } from "../../types";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-draggable"
@@ -89,15 +97,15 @@ const props = defineProps({
type: Boolean,
default: false
},
/** 动画持续时间(毫秒) */
animation: {
type: Number,
default: 150
},
/** 列数1为单列纵向布局>1为多列网格布局 */
columns: {
type: Number,
default: 1
},
// 是否需要长按触发
longPress: {
type: Boolean,
default: true
}
});
@@ -376,12 +384,6 @@ function getItemStyle(index: number) {
return style;
}
// 为非拖拽元素添加过渡动画
if (props.animation > 0 && !isCurrent) {
style["transition-property"] = "transform";
style["transition-duration"] = `${props.animation}ms`;
}
// 拖拽状态下的样式处理
if (dragging.value) {
if (isCurrent) {
@@ -391,9 +393,7 @@ function getItemStyle(index: number) {
} else {
// 其他元素:显示排序预览位移
const translateOffset = getItemTranslateOffset(index);
if (translateOffset.x != 0 || translateOffset.y != 0) {
style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
}
style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
}
}
@@ -513,22 +513,31 @@ function checkMovedToOtherElement(): boolean {
* @param event 触摸事件对象
* @param index 触摸的项目索引
*/
async function onTouchStart(event: UniTouchEvent, index: number): Promise<void> {
async function onTouchStart(event: UniTouchEvent, index: number, type: string) {
// 如果是长按触发,但未开启长按功能,则直接返回
if (type == "longpress" && !props.longPress) return;
// 如果是普通触摸触发,但已开启长按功能,则直接返回
if (type == "touch" && props.longPress) return;
// 检查是否禁用或索引无效
if (props.disabled) return;
if (getItemDisabled(index)) return;
if (index < 0 || index >= list.value.length) return;
// 获取触摸点
const touch = event.touches[0];
// 初始化拖拽状态
dragging.value = true;
// 初始化拖拽索引
dragIndex.value = index;
insertIndex.value = index; // 初始插入位置为原位置
startX.value = touch.clientX;
startY.value = touch.clientY;
offsetX.value = 0;
offsetY.value = 0;
// 初始化拖拽数据项
dragItem.value = list.value[index];
// 先获取所有项目的位置信息,为后续计算做准备
@@ -536,6 +545,14 @@ async function onTouchStart(event: UniTouchEvent, index: number): Promise<void>
// 触发开始事件
emit("start", index);
// 震动
vibrate(1);
// 阻止事件冒泡
event.stopPropagation();
// 阻止默认行为
event.preventDefault();
}
/**
@@ -575,7 +592,7 @@ function onTouchMove(event: TouchEvent): void {
}
}
// 阻止默认行为和事件冒泡
// 阻止默认行为
event.preventDefault();
}
@@ -585,7 +602,10 @@ function onTouchMove(event: TouchEvent): void {
function onTouchEnd(): void {
if (!dragging.value) return;
// 旧索引
const oldIndex = dragIndex.value;
// 新索引
const newIndex = insertIndex.value;
// 如果位置发生变化,立即更新数组
@@ -604,15 +624,11 @@ function onTouchEnd(): void {
dropping.value = true;
dragging.value = false;
// 让拖拽元素回到自然位置(偏移归零)
offsetX.value = 0;
offsetY.value = 0;
// 重置所有状态
reset();
// 等待放下动画完成后重置所有状态
setTimeout(() => {
emit("end", newIndex >= 0 ? newIndex : oldIndex);
reset();
}, 10);
emit("end", newIndex >= 0 ? newIndex : oldIndex);
}
/**
@@ -652,21 +668,30 @@ watch(
<style lang="scss" scoped>
.cl-draggable {
@apply flex-col relative overflow-visible;
}
.cl-draggable--grid {
@apply flex-row flex-wrap;
}
&--columns {
@apply flex-row flex-wrap;
}
.cl-draggable__item {
@apply relative;
}
&__item {
@apply relative z-10;
.cl-draggable__item--dragging {
@apply opacity-80;
}
// #ifdef APP-IOS
@apply transition-none opacity-100;
// #endif
.cl-draggable__item--disabled {
@apply opacity-60;
&--dragging {
@apply opacity-80 z-20;
}
&--disabled {
@apply opacity-60;
}
&--animating {
@apply duration-200;
transition-property: transform;
}
}
}
</style>

View File

@@ -22,8 +22,6 @@
:hold-keyboard="false"
:clearable="false"
@change="onChange"
@focus="animateCursor(true)"
@blur="animateCursor(true)"
></cl-input>
</view>
@@ -36,12 +34,13 @@
{
'is-disabled': disabled,
'is-dark': isDark,
'is-active': value.length == index && isFocus
'is-active': value.length >= index && isFocus
},
pt.item?.className
]"
>
<cl-text
:color="value.length >= index && isFocus ? 'primary' : ''"
:pt="{
className: pt.value?.className
}"
@@ -50,9 +49,6 @@
<view
class="cl-input-otp__cursor"
:class="[pt.cursor?.className]"
:style="{
opacity: cursorOpacity
}"
v-if="value.length == index && isFocus && item == ''"
></view>
</view>
@@ -184,51 +180,6 @@ function onChange(val: string) {
emit("done", val);
}
}
/**
* 光标闪烁透明度值
* 范围: 0.3-1.0
*/
const cursorOpacity = ref(0.3);
/**
* 光标闪烁动画帧ID
*/
let cursorAnimationId = 0;
/**
* 控制光标闪烁动画
* @param isIncreasing 透明度是否递增
*/
function animateCursor(isIncreasing: boolean) {
// #ifdef APP
// 未获得焦点时不执行动画
if (!isFocus.value) {
return;
}
// 取消上一次动画
if (cursorAnimationId != 0) {
cancelAnimationFrame(cursorAnimationId);
cursorAnimationId = 0;
}
// 执行动画帧
cursorAnimationId = requestAnimationFrame(() => {
// 根据方向调整透明度值
cursorOpacity.value += isIncreasing ? 0.01 : -0.01;
// 到达边界值时改变方向
if (cursorOpacity.value > 1) {
animateCursor(false);
} else if (cursorOpacity.value <= 0.3) {
animateCursor(true);
} else {
animateCursor(isIncreasing);
}
});
// #endif
}
</script>
<style lang="scss" scoped>
@@ -248,7 +199,7 @@ function animateCursor(isIncreasing: boolean) {
}
&__item {
@apply flex flex-row items-center justify-center;
@apply flex flex-row items-center justify-center duration-100;
@apply border border-solid border-surface-200 rounded-lg bg-white;
height: 80rpx;
width: 80rpx;
@@ -272,10 +223,10 @@ function animateCursor(isIncreasing: boolean) {
}
&__cursor {
@apply absolute;
@apply absolute duration-100;
@apply bg-primary-500;
width: 2rpx;
height: 36rpx;
height: 24rpx;
}
// #ifdef H5 || MP
@@ -285,7 +236,7 @@ function animateCursor(isIncreasing: boolean) {
@keyframes flash {
0% {
opacity: 0.3;
opacity: 0;
}
50% {
@@ -293,7 +244,7 @@ function animateCursor(isIncreasing: boolean) {
}
100% {
opacity: 0.3;
opacity: 0;
}
}
// #endif

View File

@@ -1,33 +1,25 @@
<template>
<view
ref="loadingRef"
class="cl-loading"
:class="[
{
'cl-loading--dark': isDark && color == '',
'cl-loading--spin': loading,
'!border-primary-500': color == 'primary',
'!border-green-500': color == 'success',
'!border-yellow-500': color == 'warn',
'!border-red-500': color == 'error',
'!border-surface-500': color == 'info',
'!border-surface-700': color == 'dark',
'!border-white': color == 'light',
'!border-surface-300': color == 'disabled',
'!border-r-transparent': true
},
pt.className
]"
:style="{
// #ifdef APP
transform: `rotate(${rotate}deg)`,
// #endif
height: getRpx(size!),
width: getRpx(size!),
borderWidth: getRpx(2),
height: getPx(size!),
width: getPx(size!),
// #ifndef APP
borderWidth: '1px',
borderTopColor: color,
borderRightColor: 'transparent',
borderBottomColor: color,
borderLeftColor: color
// #endif
}"
v-if="loading"
>
@@ -35,8 +27,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { isDark, parsePt } from "@/cool";
import { computed, nextTick, onMounted, ref, shallowRef, watch } from "vue";
import { ctx, isDark, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { useSize } from "../../hooks";
@@ -68,7 +60,7 @@ const props = defineProps({
}
});
const { getRpx } = useSize();
const { getPxValue, getPx } = useSize();
// 透传样式类型定义
type PassThrough = {
@@ -79,25 +71,113 @@ type PassThrough = {
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 旋转角度
const rotate = ref(0);
// 组件引用
const loadingRef = shallowRef<UniElement | null>(null);
const color = computed<string>(() => {
if (props.color == "") {
return isDark.value ? "#ffffff" : (ctx.color["surface-700"] as string);
}
switch (props.color) {
case "primary":
return ctx.color["primary-500"] as string;
case "success":
return "#22c55e";
case "warn":
return "#eab308";
case "error":
return "#ef4444";
case "info":
return "#71717a";
case "dark":
return "#3f3f46";
case "light":
return "#ffffff";
case "disabled":
return "#d4d4d8";
default:
return props.color;
}
});
async function drawLoading() {
// #ifdef APP
await nextTick();
if (loadingRef.value == null) {
return;
}
const drawContext = loadingRef.value!.getDrawableContext();
// 重置画布准备绘制新的loading图形
drawContext!.reset();
drawContext!.beginPath();
// 获取loading图标的尺寸和半径
const size = getPxValue(props.size!);
const radius = size / 2;
const centerX = radius;
const centerY = radius;
// 设置线宽
const lineWidth = 1;
// 缺口角度为60度Math.PI / 3用于形成loading的缺口效果
const gapAngle = Math.PI / 3; // 缺口60度
// 起始角度为顶部(-90度
const startAngle = -Math.PI / 2; // 从顶部开始
// 结束角度为起始角度加上300度360-60形成环形缺口
const endAngle = startAngle + (2 * Math.PI - gapAngle); // 画300度
// 绘制圆弧形成loading环
drawContext!.arc(centerX, centerY, radius - lineWidth, startAngle, endAngle, false);
// 设置描边颜色和线宽
drawContext!.strokeStyle = color.value;
drawContext!.lineWidth = lineWidth;
// 执行描边操作
drawContext!.stroke();
// 更新画布显示
drawContext!.update();
// #endif
}
// 开始旋转动画
function start() {
requestAnimationFrame(() => {
// 增加旋转角度
rotate.value += 1;
async function start() {
// #ifdef APP
await drawLoading();
// 如果仍在加载中则继续旋转
if (props.loading) {
start();
if (loadingRef.value == null) {
return;
}
loadingRef.value!.animate(
[
{
transform: "rotate(0deg)"
},
{
transform: "rotate(360deg)"
}
],
{
duration: 2500,
easing: "linear",
iterations: 999999
}
});
);
// #endif
}
// 组件挂载后监听loading状态
onMounted(() => {
// #ifdef APP-UVUE
// #ifdef APP
watch(
computed(() => props.loading),
(val: boolean) => {
@@ -110,6 +190,13 @@ onMounted(() => {
immediate: true
}
);
watch(
computed(() => [props.color, props.size, isDark.value]),
() => {
drawLoading();
}
);
// #endif
});
</script>
@@ -117,7 +204,10 @@ onMounted(() => {
<style lang="scss" scoped>
.cl-loading {
@apply flex flex-row items-center justify-center rounded-full;
// #ifndef APP
@apply border-surface-700 border-solid;
// #endif
&--dark {
border-color: white !important;

View File

@@ -2,7 +2,7 @@
<view class="cl-loadmore-wrapper">
<view class="cl-loadmore">
<cl-loading
:size="30"
:size="28"
:pt="{
className: `mr-2 ${pt.icon?.className}`
}"
@@ -59,7 +59,7 @@ const props = defineProps({
// 是否显示底部安全区
safeAreaBottom: {
type: Boolean,
default: true
default: false
}
});

View File

@@ -384,7 +384,7 @@ function onTouchEnd() {
angle: currentAngle.value
});
}
vibrate(2); // 震动反馈
vibrate(1); // 震动反馈
}
// 监听模式变化,重新初始化

View File

@@ -1,5 +1,6 @@
import { computed, type ComputedRef } from "vue";
import { config } from "../config";
import { rpx2px } from "@/cool";
/**
* 字号管理类
@@ -73,6 +74,35 @@ class Size {
}
};
/**
* 获取px值
* @param val - 需要转换的值 10、10rpx、10px
* @returns 转换后的px值
*/
getPxValue = (val: number | string) => {
const scale = this.getScale();
if (typeof val == "string") {
const num = parseFloat(val);
const unit = val.replace(`${num}`, "");
if (unit == "px") {
return num * scale;
} else {
return rpx2px(num * scale);
}
} else {
return rpx2px(val * scale);
}
};
/**
* 获取px值
*/
getPx = (val: number | string) => {
return this.getPxValue(val) + "px";
};
/**
* 获取当前字号在预设中的索引
*/