ParallelogramButton

This commit is contained in:
郭文文 2026-01-20 15:54:03 +08:00
parent 963790e355
commit 5539a89dcc
2 changed files with 205 additions and 18 deletions

View File

@ -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<ActiveTab>('')
const [activeTab, setActiveTab] = useState<ActiveTab>('HOT')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [allItems, setAllItems] = useState<MediaItem[]>([])
@ -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 (
<Block className="mt-[40px] items-center justify-center gap-[16px] py-[60px]">
<Block className="size-[80px] items-center justify-center rounded-full border-4 border-white/20 bg-white/10">
@ -648,29 +650,32 @@ type FilterSectionProps = {
const FilterSection = memo<FilterSectionProps>(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 (
<Block className="mt-[12px] flex-row items-end justify-between">
<Block className="flex-row gap-[8px]">
<Block className="flex-row gap-[4px]">
{tabs.map(({ label, state }) => {
const isActive = activeTab === state
// 估算宽度:根据文本长度动态计算
// 中文字符大约 13px字体13pxpadding 左右各 16px
const buttonWidth = Math.max(label.length * 13 + 12, 66) // 最小宽度 70适应13px字体
const buttonHeight = 30 // 稍微增加高度以适应13px字体
return (
<Block
className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-accent' : 'bg-white'}`}
<ParallelogramButton
key={state}
onClick={() => onChange(state)}
style={{
transform: [{ skewX: '-6deg' }],
}}
>
<Text className={`text-[10px] font-[900] ${isActive ? 'text-black' : 'text-gray-500'}`}>{label}</Text>
</Block>
label={label}
state={state}
isActive={isActive}
onPress={() => onChange(state)}
width={buttonWidth}
height={buttonHeight}
/>
)
})}
</Block>

View File

@ -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<ParallelogramButtonProps>(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 (
<Pressable onPress={onPress} style={{ width, height: canvasHeight }}>
<Canvas
style={{
width: canvasWidth,
height: canvasHeight,
// 向左偏移 padding让额外画布空间不参与横向布局计算
marginLeft: -padding,
}}
>
{/* 背景 - 平行四边形 */}
<Path
path={parallelogramPath}
color={isActive ? Skia.Color('#FFE500') : Skia.Color('#FFFFFF')}
/>
{/* 边框 - 平行四边形 */}
<Path path={borderPath} color={Skia.Color('#000000')} style="stroke" strokeWidth={2} />
{/* 英文 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 (
<Group
transform={[
{ translateX: centerX },
{ translateY: centerY },
{ rotate: rotationAngle },
{ translateX: -centerX + stateTextX },
{ translateY: -centerY + stateTextY },
]}
>
<Paragraph paragraph={stateParagraph} x={0} y={0} width={40} />
</Group>
)
})()}
{/* 中文文本 - 以左下角为中心,逆时针旋转 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 (
<Group
transform={[
{ translateX: centerX },
{ translateY: centerY },
{ rotate: rotationAngle },
{ translateX: -centerX + textX },
{ translateY: -centerY + textY },
]}
>
<Paragraph paragraph={paragraph} x={0} y={0} width={width - 32 - skewOffset} />
</Group>
)
})()}
</Canvas>
</Pressable>
)
})
ParallelogramButton.displayName = 'ParallelogramButton'
export default ParallelogramButton