Skip to content

Latest commit

 

History

History

README.md

LockFlow: Desbloqueio por Padrão (3×3) no React Native — do gesto ao fluxo completo

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.

Sumário

Demo

LockFlow.mov

Pré‑requisitos

  • Node 20+, Java 17 (Android), Xcode + CocoaPods (iOS), Watchman (macOS)
  • React Native CLI 0.82

Instalação e configuração

Dependências

No diretório do app, instale as dependências (já listadas em package.json):

npm install

Principais libs usadas:

  • @react-navigation/native e @react-navigation/native-stack
  • react-native-screens e react-native-safe-area-context
  • react-native-svg (desenho do padrão)

iOS (CocoaPods)

cd ios && pod install && cd ..

Não há configuração especial de Babel aqui; o projeto usa Animated do React Native e PanResponder sem plugins adicionais.

Arquitetura do lock

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.

Implementação passo a passo

Abaixo, os arquivos principais com trechos relevantes.

1) Navegação e fluxo

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>
  );
}

2) Utilitários de grid e regras

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;
}

3) Hook de estado, gestos e animações

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?.());

4) Canvas SVG (Polyline e círculos)

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>

5) Callbacks e navegação

  • 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}
/>

6) Parametrização e estilos

  • Ajuste BOX_SIZE, PADDING, DOT_R, MOVE_HIT_R em src/pattern/utils/geometry.ts.
  • Cores do fundo/frame estão em styles da tela PatternUnlockScreen (paleta azul de exemplo).
  • Para acessibilidade: adicione sons/háptica extra e verifique contraste de cores.

Como executar

npm install
cd ios && pod install && cd ..
npm run start
npm run ios      # ou
npm run android

Estrutura de pastas (referência)

LockFlow/
  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

Solução de problemas

  • iOS/Pods: sempre rode pod install apó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 do Svg e 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.

Referências