Skip to content

BinaryMoura/PhobosScroll

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PhobosScroll

License: MIT

A cinematic sci-fi character roster built with Expo SDK 55 and React Native 0.83. Features scroll-driven animations, real-time dominant-color extraction, procedural energy circuit effects, and a futuristic skeleton loader — all running on the native thread at 60 FPS.


Demo

PhobosScroll animation


Design & Concept

Wireframe, concept, and final product


Overview

  • An animated scrollable list where items scale and fade based on scroll position.
  • Real-time dominant-color extraction from local portrait images to tint each card uniquely.
  • Procedural energy effects that travel around card borders, simulating a charging neural network.
  • A futuristic skeleton loading state with electric-blue pulses.
  • Cinematic data mocks: each character has a name, age, vital points (VP), planetary origin, and a lore description.

Stack

  • Expo SDK 55
  • React Native 0.83
  • TypeScript

Animated FlatList

Core Mechanism

The list uses Animated.FlatList with useNativeDriver: true. A single Animated.Value called scrollY tracks the vertical content offset.

const scrollY = useRef(new Animated.Value(0)).current;

The onScroll event is mapped directly into scrollY:

onScroll={Animated.event(
  [{ nativeEvent: { contentOffset: { y: scrollY } } }],
  { useNativeDriver: true }
)}

Because useNativeDriver: true, the interpolation runs on the native thread (iOS Core Animation / Android RenderThread), guaranteeing 60 FPS even during heavy scroll gestures.

Per-Item Interpolation

Each item computes its own scale and opacity using scrollY.interpolate. The inputRange is derived from the item's index and a constant itemSize (card height + margin).

itemSize = itemHeight + spacing   // 150 + 20 = 170

inputRange = [
  -1,                        // before the list starts
  0,                         // top of the list
  itemSize * index,          // when this item reaches the top
  itemSize * (index + 2)     // when this item is two slots past the top
]

outputRange for scale  = [1, 1, 1, 0]
outputRange for opacity = [1, 1, 1, 0]

Mathematical insight:

  • When the user scrolls, y increases.
  • At y = itemSize * index, the item is at the top edge; it is still fully visible (output = 1).
  • By the time y = itemSize * (index + 2), the item has traveled two full card-lengths past the viewport. It reaches output = 0 and vanishes.
  • The -1 and 0 anchors ensure items at the very top of the list never scale down before scrolling begins.

Performance Implications

  • No JS thread involvement during scroll. All calculations are native.
  • No re-renders on scroll. The component tree remains static; only native style properties mutate.
  • O(1) memory per item. Each item holds two AnimatedInterpolation objects, not cloned Animated.Values.

Real-Time Color Extraction

Library Choice

We use react-native-image-colors (v2.6.0). It wraps:

  • Android: androidx.palette.graphics.Palette
  • iOS: UIImageColors
  • Web: node-vibrant

Extraction Flow

  1. Each local image is resolved to a URI via Image.resolveAssetSource(require(...)).uri.
  2. getColors(uri, { cache: true, fallback: "#7d4b3e" }) returns a platform-specific palette.
  3. We normalize the result:
    • iOSresult.primary || result.background
    • Androidresult.vibrant || result.dominant || result.average
  4. The hex color is stored in a Record<string, string> keyed by character ID.

Hook Architecture

useCharacterColors(items) → { colors, loading }
  • Runs once on mount via useEffect.
  • Uses Promise.all to extract all 12 palettes in parallel.
  • Exposes loading so the UI can gate rendering behind a skeleton screen.

Application

The raw hex color is converted to an RGBA string with low opacity (0.35) so it acts as a tinted glass panel rather than a solid block:

hexToRgba(color, 0.35)   // e.g. "#4A90D9" → "rgba(74, 144, 217, 0.35)"

Color extraction triggers image decoding and pixel iteration (native C++/Objective-C). On low-end devices this can take 200–600 ms. The skeleton state masks this latency.


Energy Circuit Effect (Perimetral Laser)

Visual Goal

Simulate a charged particle of light traveling around the card border, leaving a glowing trail behind it — like a laser cutter etching a rectangle.

Animation Strategy

We use a single Animated.Value named circuit that loops from 0 → 1 over 2000 ms. Four sides of the card map to four sub-ranges:

Side Input Range Motion
Top 0.00 – 0.23 Left → Right
Right 0.23 – 0.48 Top → Bottom
Bottom 0.48 – 0.73 Right → Left
Left 0.73 – 0.98 Bottom → Top

Each side has two synchronized actors:

  1. The Trail — a thin bar whose length grows via scaleX or scaleY.
  2. The Orb — a 3 px white dot whose position follows the tip of the trail via translateX/translateY.

The Math: Compensated Scaling

React Native's scaleX / scaleY default origin is the center of the view. To make a bar grow from one end (e.g., left-to-right), we must translate the center to that end before scaling.

Top Trail (grow from left)

Container: absolute, top=0, left=0, width=CARD_W, height=1
Inner bar: width=CARD_W, height=1
Transform: [{ translateX: -CARD_W/2 }, { scaleX: trailTop }]
  • At scaleX = 0, the bar collapses to its left edge (x = 0).
  • At scaleX = 1, it spans the full width.

Right Trail (grow from top)

Container: absolute, top=0, right=0, width=1, height=CARD_H
Inner bar: width=1, height=CARD_H
Transform: [{ translateY: -CARD_H/2 }, { scaleY: trailRight }]

Bottom Trail (grow from right)

Container: absolute, bottom=0, right=0, width=CARD_W, height=1
Inner bar: width=CARD_W, height=1
Transform: [{ translateX: +CARD_W/2 }, { scaleX: trailBottom }]
  • Positive translateX shifts the center to the right edge.
  • scaleX then expands leftward from that edge.

Left Trail (grow from bottom)

Container: absolute, bottom=0, left=0, width=1, height=CARD_H
Inner bar: width=1, height=CARD_H
Transform: [{ translateY: +CARD_H/2 }, { scaleY: trailLeft }]
  • Positive translateY shifts the center to the bottom edge.
  • scaleY then expands upward from that edge.

Orb Positioning

The orb uses the same circuit interpolations but maps to absolute displacement:

orbTopX    = interpolate(circuit, [0, 0.23],    [0, CARD_W - DOT])
orbRightY  = interpolate(circuit, [0.23, 0.48], [0, CARD_H - DOT])
orbBottomX = interpolate(circuit, [0.48, 0.73], [0, -(CARD_W - DOT)])
orbLeftY   = interpolate(circuit, [0.73, 0.98], [0, -(CARD_H - DOT)])

The orb sits in a corner (e.g., top: -DOT/2, left: -DOT/2) and is pushed along by translateX/translateY. The -DOT/2 offset centers the 3 px dot exactly on the border corner.

Drain Sweep

A secondary Animated.Value called drain translates a vertical light bar from x = -TRAIL to x = CARD_W + TRAIL in a loop, simulating energy being scanned or siphoned left-to-right across the card surface.


Disintegration Effect (Hologram Failure & Restore)

When the energy jumps to a new card, the previous card enters a fading state for ~1300 ms, mimicking a holographic transmission failing and being reestablished.

Phase Overview

Phase Time Description
Disintegration 0 – ~705 ms 10 horizontal slices consume the card top-to-bottom
Void ~705 – ~830 ms Card content fades to zero opacity
Restore ~830 – ~1280 ms Card snaps back with a cyan flash

Disintegration Slices

The card (150 px tall) is divided into 10 horizontal slices of 15 px each, rendered as absolutely-positioned Animated.Views inside the card. Slice colors alternate between cyan (rgba(0, 212, 255, 0.88)) and deep-space dark (rgba(0, 10, 30, 0.85)), producing a corrupted-signal banding pattern.

Each slice is staggered by 45 ms and runs independent opacity + translateX animations:

Opacity:
  0 → 0.92  (35 ms)   — snap on
  0.92 → 0.25 (25 ms) — flicker
  0.25 → 0.88 (35 ms) — partial recovery
  0.88 → 0   (210 ms) — signal lost

TranslateX (unique jitter per slice):
  0 → +jitter (38 ms)
  +jitter → -jitter (38 ms)
  -jitter → +jitter*0.55 (38 ms)
  +jitter*0.55 → 0 (38 ms)

Jitter values (px) per slice index: [10, -14, 8, -11, 13, -9, 12, -7, 9, -13]

Void & Restore

  1. Void: contentOpacity animates 1 → 0 (50 ms), then holds 75 ms.
  2. Restore: three animations in parallel:
    • contentOpacity: 0 → 1 (40 ms)
    • restoreScale: 1 → 1.025 → 1 (70 ms + 200 ms) — subtle reconnect pop
    • restoreFlash opacity: 0 → 0.7 → 0 (70 ms + 380 ms) — cyan wash

Full Cycle Timeline

0 ms       — activeIndex = C, fadingIndex = B
0–300 ms   — B's glow fades out (pulseAnim → 0)
0–705 ms   — B's slices disintegrate top-to-bottom
705–830 ms — B is void
830–1280 ms — B restores (flash + scale)
1300 ms    — fadingIndex = null (B is now a normal idle card)
2200 ms    — next cycle begins

Futuristic Skeleton Loader

While fonts and color palettes load, a FlatList of 12 FuturisticSkeleton items is shown.

Visual Language

  • Dark semi-transparent background (rgba(5, 10, 25, 0.7))
  • Electric-blue (#00d4ff) pulsing border with animated opacity and shadowOpacity
  • A shimmer streak (translateX loop) that sweeps across each row
  • Circular and rectangular placeholders mimicking the final layout

Why a Skeleton?

Font loading (@expo-google-fonts/poppins) + image color extraction are asynchronous. Rendering the real list immediately would cause a flash of unstyled text and cards snapping from fallback colors to extracted colors. The skeleton provides a perceived performance buffer.


Safe Area & Layout

We use react-native-safe-area-context rather than the core SafeAreaView for reliable behavior on notched devices.

const insets = useSafeAreaInsets();
paddingTop:    insets.top + spacing
paddingBottom: insets.bottom + spacing * 2

This guarantees the list never starts under the Dynamic Island / notch and never ends flush against the home indicator.


Replicating Without Advanced Libraries

Every effect can be reproduced using only core React Native APIs (Animated, Image, FlatList, View, Text).

Replacing react-native-image-colors

Option A — Deterministic Hash Colors

function stringToColor(str: string): string {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = str.charCodeAt(i) + ((hash << 5) - hash);
  }
  const hue = Math.abs(hash % 360);
  return `hsl(${hue}, 70%, 50%)`;
}

Option B — Pre-computed Palette
Use a Node script (or manual eyedropper) to extract colors from the 12 images at build time and hardcode them in characters.ts.

Replacing react-native-safe-area-context

import { Platform, StatusBar } from "react-native";

const topInset = Platform.OS === "ios" ? 50 : StatusBar.currentHeight || 0;
const bottomInset = Platform.OS === "ios" ? 34 : 0;

Font Loading Fallback

import { useFonts } from "expo-font";
const [loaded] = useFonts({
  PoppinsRegular: require("./assets/fonts/Poppins-Regular.ttf"),
});

Performance Budget

Operation Thread Approx. Cost
Scroll interpolation Native 0 ms JS
Color extraction (12 images) Native (C++) 150–500 ms total
Circuit animation Native 0 ms JS
Disintegration (20 Animated.Values per card) Native 0 ms JS
Skeleton shimmer Native 0 ms JS
Font loading Native (iOS) / JS parse 200–400 ms

Animated.Value count per card: 3 (pulseAnim, circuit, drain) + 10 (sliceOpacity) + 10 (sliceTx) + 3 (contentOpacity, restoreFlash, restoreScale) = 26 values, all with useNativeDriver: true.

Recommended benchmark scenarios:

  1. Cold start: measure time from App mount to skeleton disappearance.
  2. Scroll stress: fling the list at maximum velocity and monitor dropped frames.
  3. Energy cycle stress: reduce the interval to 100 ms and verify the JS thread stays below 16 ms per frame.
  4. Low-end device: run on an old Android device with useNativeDriver: false to measure JS thread impact.

Asset Inventory

Asset Count Source
Character portraits 12 (6M / 6F) Local src/data/pictures/
Background image 1 Unsplash URI
Fonts 4 variants @expo-google-fonts/poppins

License

MIT © Leonardo Moura (Binary Moura)


Glossary

Term Meaning
inputRange Scroll Y positions that map to animation keyframes
outputRange The style values corresponding to inputRange
extrapolate: "clamp" Prevents the interpolation from exceeding the output range
useNativeDriver Forces animation evaluation on the native UI thread
VP Vital Points — the sci-fi health metric

About

a sci-fi character list app where every card has a living border, a unique color pulled directly from the portrait photo, and a holographic disintegration effect when its energy transfers to another card.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors