Crie uma tela de bloqueio estilo Android com padrão 3×3: arraste e conecte pontos, receba feedback visual, anime o sucesso e navegue para o dashboard. Este guia mostra a arquitetura, as regras do padrão (incluindo pontos intermediários) e como desenhar tudo com SVG de forma performática.
- Demo
- Pré‑requisitos
- Instalação e configuração
- Arquitetura do lock
- Implementação passo a passo
- Como executar
- Estrutura de pastas (referência)
- Solução de problemas
- Referências
LockFlow.mov
- Node 20+, Java 17 (Android), Xcode + CocoaPods (iOS), Watchman (macOS)
- React Native CLI 0.82
No diretório do app, instale as dependências (já listadas em package.json):
npm installPrincipais libs usadas:
@react-navigation/nativee@react-navigation/native-stackreact-native-screensereact-native-safe-area-contextreact-native-svg(desenho do padrão)
cd ios && pod install && cd ..Não há configuração especial de Babel aqui; o projeto usa
Animateddo React Native ePanRespondersem plugins adicionais.
A implementação foi organizada em camadas para manutenção e reuso:
utils— matemática/geom. do grid (centros, vizinhança, ponto intermediário), constantes e helpers de feedback.hooks— orquestra estado, gestos (PanResponder), linha viva, progress por ponto e animações de sucesso.components— canvas SVG que somente desenha, desacoplado da lógica.screen— compõe tudo e expõe props simples (registeredPattern,onSuccess,onFail).
Benefícios: responsabilidades claras, testes mais fáceis, e possibilidade de reuso do hook/canvas em outras telas ou temas.
Abaixo, os arquivos principais com trechos relevantes.
Arquivo: App.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaView, StatusBar } from 'react-native';
import { PatternUnlockScreen } from './src/PatternUnlockScreen';
import { DashboardScreen } from './src/DashboardScreen.tsx';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<StatusBar barStyle="light-content" />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Lock" component={LockWrapper} />
<Stack.Screen name="Dashboard" component={DashboardScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
function LockWrapper({ navigation }: any) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0D80FF' }}>
<PatternUnlockScreen
registeredPattern={[6, 3, 0, 4, 2, 5, 8]}
onSuccess={() => navigation.replace('Dashboard')}
/>
</SafeAreaView>
);
}Arquivo: src/pattern/utils/geometry.ts
Constantes e centros do grid:
export const BOX_SIZE = 300;
export const PADDING = 48;
export const DOT_R = 12;
export const MOVE_HIT_R = 24;
export const GRID_INDEXES = [...Array(9).keys()];
export type Point = { x: number; y: number };
export function centersForGrid(): Point[] {
const area = BOX_SIZE - PADDING * 2;
const step = area / 2;
const base = PADDING;
const pts: Point[] = [];
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
pts.push({ x: base + c * step, y: base + r * step });
}
return pts;
}Detecção de ponto intermediário (saltos em linha/coluna/diagonal):
export function intermediateIndex(a: number, b: number): number | null {
const A = idxToRC(a), B = idxToRC(b);
const dr = B.r - A.r, dc = B.c - A.c;
const isStraightTwo =
(A.r === B.r && Math.abs(dc) === 2) ||
(A.c === B.c && Math.abs(dr) === 2) ||
(Math.abs(dr) === 2 && Math.abs(dc) === 2);
if (!isStraightTwo) return null;
const mid = { r: (A.r + B.r) / 2, c: (A.c + B.c) / 2 };
return (Number.isInteger(mid.r) && Number.isInteger(mid.c)) ? (mid.r * 3 + mid.c) : null;
}Seleção por proximidade durante o arrasto:
export function nearestIndexWithin(p: Point, centers: Point[], radius: number): number | null {
let idx: number | null = null, best = Number.MAX_VALUE;
centers.forEach((c, i) => {
const d = Math.hypot(p.x - c.x, p.y - c.y);
if (d < best) { best = d; idx = i; }
});
return best <= radius ? idx : null;
}Arquivo: src/pattern/hooks/usePatternLock.ts
PanResponder (trecho principal):
const pan = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (e: GestureResponderEvent) => {
reset();
const p = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
livePoint.current = p; setLivePointState(p);
const firstIdx = nearestIndexAny(p, centers);
pathRef.current = [firstIdx];
_setSelected([firstIdx]);
},
onPanResponderMove: (e: GestureResponderEvent) => {
const p = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
livePoint.current = p; scheduleCursorUpdate();
setSelectedSync(prev => {
const last = prev[prev.length - 1];
const idx = nearestIndexWithin(p, centers, MOVE_HIT_R);
if (idx == null || prev.includes(idx)) return prev;
if (!isAdjacentOrTwoSteps(last, idx)) return prev;
const next = [...prev];
const mid = intermediateIndex(last, idx);
if (mid != null && !next.includes(mid)) next.push(mid);
next.push(idx);
return next;
});
},
onPanResponderRelease: () => finish(),
onPanResponderTerminate: () => finish(),
})
).current;Sequência de animação de sucesso:
Animated.sequence([
Animated.parallel([
Animated.timing(frameScale, { toValue: 1.08, duration: 140, useNativeDriver: true }),
Animated.timing(haloScale, { toValue: 1.15, duration: 140, useNativeDriver: true }),
]),
Animated.parallel([
Animated.timing(frameScale, { toValue: 0.82, duration: 260, easing: Easing.out(Easing.quad), useNativeDriver: true }),
Animated.timing(frameOpacity, { toValue: 0, duration: 260, useNativeDriver: true }),
Animated.timing(haloScale, { toValue: 1.6, duration: 260, useNativeDriver: true }),
]),
]).start(({ finished }) => finished && onSuccess?.());Arquivo: src/pattern/components/PatternCanvas.tsx
Trecho de desenho:
<Svg width={width} height={height}>
{linePoints.length > 1 && (
<Polyline
points={linePoints.map(p => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke={strokeColor}
strokeOpacity={0.9}
strokeWidth={4}
strokeLinejoin="miter"
strokeLinecap="butt"
/>
)}
{GRID_INDEXES.map((i) => {
const c = centers[i];
const rAnimated = dotProgress[i].interpolate({ inputRange: [0, 1], outputRange: [DOT_R, DOT_R * 1.6] });
const fillOpacity = dotProgress[i].interpolate({ inputRange: [0, 1], outputRange: [0, 0.22] });
return (
<React.Fragment key={i}>
<AnimatedCircle cx={c.x} cy={c.y} r={rAnimated as any} fill="#FFFFFF" fillOpacity={fillOpacity as any} />
<Circle cx={c.x} cy={c.y} r={DOT_R} stroke="white" strokeOpacity={0.95} strokeWidth={3} fill="transparent" />
</React.Fragment>
);
})}
</Svg>Uso na tela com strokeColor por status:
const strokeColor =
status === 'ok' ? '#22C55E' :
status === 'fail' ? '#EF4444' :
'rgba(255,255,255,0.95)';
<Animated.View style={[styles.phoneFrame, { transform: [{ scale: frameScale }], opacity: frameOpacity }]}>
<View style={{ width: BOX_SIZE, height: BOX_SIZE, alignSelf: 'center' }} {...panHandlers}>
<PatternCanvas
width={BOX_SIZE}
height={BOX_SIZE}
linePoints={linePoints}
centers={centers}
strokeColor={strokeColor}
dotProgress={dotProgress}
/>
</View>
</Animated.View>registeredPattern: array de índices esperados (0–8).onSuccess: chamado após animação de sucesso (no exemplo,navigation.replace('Dashboard')).onFail: vibração e reset após um breve atraso.
<PatternUnlockScreen
registeredPattern={[6, 3, 0, 4, 2, 5, 8]}
onSuccess={() => navigation.replace('Dashboard')}
onFail={() => {}}
showDebug={false}
/>- Ajuste
BOX_SIZE,PADDING,DOT_R,MOVE_HIT_Remsrc/pattern/utils/geometry.ts. - Cores do fundo/frame estão em
stylesda telaPatternUnlockScreen(paleta azul de exemplo). - Para acessibilidade: adicione sons/háptica extra e verifique contraste de cores.
npm install
cd ios && pod install && cd ..
npm run start
npm run ios # ou
npm run androidLockFlow/
App.tsx
index.js
babel.config.js
metro.config.js
package.json
src/
PatternUnlockScreen.tsx
DashboardScreen.tsx
pattern/
components/
PatternCanvas.tsx
hooks/
usePatternLock.ts
utils/
geometry.ts
feedback.ts
- iOS/Pods: sempre rode
pod installapós instalar/atualizar libs nativas (react-native-svg). - SVG não desenha ou erro de link: limpe o build (Xcode/Gradle) e reinstale pods.
- Gesto não responde: confira
hitSlop,pointerEvents="box-only"no wrapper doSvge se nenhum outro componente está capturando o toque. - Metro/Cache: se erros estranhos surgirem, rode:
npm start -- --reset-cache- Android: confirme Java 17 e SDKs instalados.
- React Native: https://reactnative.dev/
- React Navigation: https://reactnavigation.org/
- react-native-svg: https://github.com/software-mansion/react-native-svg