From 5539a89dcc9a13070a6f4b96ba18030fc5357125 Mon Sep 17 00:00:00 2001 From: gww Date: Tue, 20 Jan 2026 15:54:03 +0800 Subject: [PATCH] ParallelogramButton --- app/(tabs)/index.tsx | 41 ++++--- components/ParallelogramButton.tsx | 182 +++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 components/ParallelogramButton.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 776527f..c089d23 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -9,6 +9,8 @@ import { observer } from 'mobx-react-lite' import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, RefreshControl, TextInput } from 'react-native' +import ParallelogramButton from '@/components/ParallelogramButton' + import BannerSection from '@/components/BannerSection' import { useTemplateActions } from '@/hooks/actions/use-template-actions' import { useTemplates } from '@/hooks/data' @@ -35,7 +37,7 @@ type MediaItem = { title?: string authorName?: string } -type ActiveTab = 'gen' | '' | 'new' | 'like' +type ActiveTab = 'gen' | 'HOT' | 'NEW' | 'LIKE' /** ========================= * Entry page @@ -50,7 +52,7 @@ const Index = observer(function Index() { /** ================= 状态 ================= */ - const [activeTab, setActiveTab] = useState('') + const [activeTab, setActiveTab] = useState('HOT') const [isSearchOpen, setIsSearchOpen] = useState(false) const [allItems, setAllItems] = useState([]) @@ -126,7 +128,7 @@ const Index = observer(function Index() { const { page, pageSize, search, tab } = queryRef.current let newItems: MediaItem[] = [] - if (tab === 'like') { + if (tab === 'LIKE') { console.log('加载收藏列表,isAuthenticated=', isAuthenticated) if (!isAuthenticated) { setHasMore(false) @@ -139,7 +141,7 @@ const Index = observer(function Index() { .map((f) => transformTemplateToMediaItem(f.template as TemplateData)) || [] } } else { - const sortBy = tab === 'new' ? 'createdAt' : 'likeCount' + const sortBy = tab === 'NEW' ? 'createdAt' : 'likeCount' const { data } = await loadTemplates({ page, limit: pageSize, @@ -342,7 +344,7 @@ const Index = observer(function Index() { } const renderListEmpty = () => { - if (activeTab === 'like' && !isAuthenticated) { + if (activeTab === 'LIKE' && !isAuthenticated) { return ( @@ -648,29 +650,32 @@ type FilterSectionProps = { const FilterSection = memo(function FilterSection({ activeTab, onChange }) { const tabs = useMemo( () => [ - { label: '最热', state: '' as const }, - { label: '最新', state: 'new' as const }, - { label: '喜欢', state: 'like' as const }, + { label: '最热', state: 'HOT' as const }, + { label: '最新', state: 'NEW' as const }, + { label: '喜欢', state: 'LIKE' as const }, ], [], ) return ( - + {tabs.map(({ label, state }) => { const isActive = activeTab === state + // 估算宽度:根据文本长度动态计算 + // 中文字符大约 13px(字体13px),padding 左右各 16px + const buttonWidth = Math.max(label.length * 13 + 12, 66) // 最小宽度 70,适应13px字体 + const buttonHeight = 30 // 稍微增加高度以适应13px字体 return ( - onChange(state)} - style={{ - transform: [{ skewX: '-6deg' }], - }} - > - {label} - + label={label} + state={state} + isActive={isActive} + onPress={() => onChange(state)} + width={buttonWidth} + height={buttonHeight} + /> ) })} diff --git a/components/ParallelogramButton.tsx b/components/ParallelogramButton.tsx new file mode 100644 index 0000000..bab9229 --- /dev/null +++ b/components/ParallelogramButton.tsx @@ -0,0 +1,182 @@ +import React, { memo, useMemo } from 'react' +import { Pressable } from 'react-native' +import { Canvas, Group, Paragraph, Path, Skia, useFonts } from '@shopify/react-native-skia' + +export type ParallelogramButtonProps = { + label: string + state: string + isActive: boolean + onPress: () => void + width: number + height: number +} + +const ParallelogramButton = memo(function ParallelogramButton({ + label, + state, + isActive, + onPress, + width, + height, +}) { + const fontMgr = useFonts({ + System: [], + }) + + // 创建英文 state 标签段落(白色字体,黑色描边,字重 700,激活时 #FAE307,未激活时白色) + const stateParagraph = useMemo(() => { + if (!fontMgr) return null + const para = Skia.ParagraphBuilder.Make({}, fontMgr) + .pushStyle({ + color: isActive ? Skia.Color('#FAE307') : Skia.Color('#FFFFFF'), + fontSize: 7, + fontFamilies: ['System'], + fontStyle: { weight: 700 }, + shadows: [ + // 黑色描边效果(略微偏移,避免过粗) + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -0.5, y: -0.5 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0.5, y: -0.5 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -0.5, y: 0.5 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0.5, y: 0.5 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -0.5, y: 0 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0.5, y: 0 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0, y: -0.5 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0, y: 0.5 } }, + ], + }) + .addText(state) + .build() + para.layout(40) + return para + }, [fontMgr, state, isActive]) + + // 创建白色字体带黑色描边的中文文本段落 + const paragraph = useMemo(() => { + if (!fontMgr) return null + const para = Skia.ParagraphBuilder.Make({}, fontMgr) + .pushStyle({ + color: Skia.Color('#FFFFFF'), + fontSize: 13, + fontFamilies: ['System'], + fontStyle: { weight: 900 }, + shadows: [ + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -1, y: -1 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 1, y: -1 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -1, y: 1 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 1, y: 1 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: -1, y: 0 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 1, y: 0 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0, y: -1 } }, + { color: Skia.Color('#000000'), blurRadius: 0, offset: { x: 0, y: 1 } }, + ], + }) + .addText(label) + .build() + para.layout(width - 12) // 减去左右 padding + return para + }, [fontMgr, label, width]) + + const skewOffset = 8 // 倾斜偏移量(像素) + const padding = 15 // 增加 padding,防止旋转后的文本被裁剪 + + // 创建平行四边形路径:只有左右边倾斜,上下边保持水平 + const parallelogramPath = useMemo(() => { + const path = Skia.Path.Make() + // 四个顶点:左上、右上、右下、左下(加上 padding 偏移) + path.moveTo(skewOffset + padding, padding) + path.lineTo(width + padding, padding) + path.lineTo(width - skewOffset + padding, height + padding) + path.lineTo(padding, height + padding) + path.close() + return path + }, [width, height, skewOffset, padding]) + + // 创建边框路径 + const borderPath = useMemo(() => { + const path = Skia.Path.Make() + path.moveTo(skewOffset + padding + 1, padding + 1) + path.lineTo(width + padding - 1, padding + 1) + path.lineTo(width - skewOffset + padding - 1, height + padding - 1) + path.lineTo(padding + 1, height + padding - 1) + path.close() + return path + }, [width, height, skewOffset, padding]) + + // 计算实际画布尺寸(包含 padding) + const canvasWidth = width + padding * 2 + const canvasHeight = height + padding * 2 + + // 注意:布局占位仍然使用原始 width,避免改变 tab 间距 + return ( + + + {/* 背景 - 平行四边形 */} + + + {/* 边框 - 平行四边形 */} + + + {/* 英文 state 标签 - 左上角,以左下角为中心,逆时针旋转 15 度 */} + {stateParagraph && (() => { + const stateTextX = 8 + skewOffset / 2 + 10 + padding + const stateTextY = 4 + padding + const rotationAngle = (-15 * Math.PI) / 180 + const centerX = padding + const centerY = height + padding + + return ( + + + + ) + })()} + + {/* 中文文本 - 以左下角为中心,逆时针旋转 15 度 */} + {paragraph && (() => { + const textX = 16 + skewOffset / 2 + 2 + padding + const textY = height - paragraph.getHeight() + 1 + padding + const rotationAngle = (-15 * Math.PI) / 180 + const centerX = padding + const centerY = height + padding + + return ( + + + + ) + })()} + + + ) +}) + +ParallelogramButton.displayName = 'ParallelogramButton' + +export default ParallelogramButton +