react-native-debug-toolkit
Version:
A simple yet powerful debugging toolkit for React Native with a convenient floating UI for development
698 lines (631 loc) • 17.7 kB
JavaScript
import React, { Component } from 'react'
import {
View,
Text,
StyleSheet,
Animated,
PanResponder,
Dimensions,
Pressable,
SafeAreaView,
ScrollView,
TouchableOpacity,
Modal,
FlatList,
} from 'react-native'
// import AsyncStorage from '@react-native-async-storage/async-storage'
import { IconRadius, DebugColors } from '../utils/DebugConst'
import SubViewHTTPLogs from './SubViewHTTPLogs'
// import SubViewPerformance from './SubViewPerformance'
import SubViewConsoleLogs from './SubViewConsoleLogs'
import SubViewZustandLogs from './SubViewZustandLogs'
import SubViewNavigationLogs from './SubViewNavigationLogs'
import SubViewThirdPartyLibs from './SubViewThirdPartyLibs'
import SubViewTrackLogs from './SubViewTrackLogs'
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
export default class FloatPanelView extends Component {
constructor(props) {
super(props)
// Calculate initial position (right edge, middle of screen)
const initialPosition = {
x: screenWidth - IconRadius - 20,
y: screenHeight / 2 - IconRadius / 2,
}
this.state = {
isOpen: false,
toFloat: true,
pan: new Animated.ValueXY(initialPosition),
scale: new Animated.Value(1),
lastPosition: initialPosition,
activeTab: 0,
panelTranslateY: new Animated.Value(screenHeight),
backdropOpacity: new Animated.Value(0),
isTabMenuVisible: false,
panelLayout: { width: 0, height: 0 },
}
this.tabScrollViewRef = React.createRef()
this.gestureResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: this._handlePanResponderGrant,
onPanResponderMove: this._handlePanResponderMove,
onPanResponderRelease: this._handlePanResponderRelease,
onPanResponderTerminate: this._handlePanResponderRelease,
})
this.panelResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_, gestureState) => {
// Only respond to downward swipes
return gestureState.dy > 5
},
onPanResponderMove: (_, gestureState) => {
if (gestureState.dy > 0) {
this.state.panelTranslateY.setValue(gestureState.dy)
// Fade backdrop based on drag distance
const newOpacity = Math.max(0, 1 - gestureState.dy / 200)
this.state.backdropOpacity.setValue(newOpacity)
}
},
onPanResponderRelease: (_, gestureState) => {
if (gestureState.dy > 100) {
this._closePanel()
} else {
Animated.spring(this.state.panelTranslateY, {
toValue: 0,
friction: 8,
tension: 50,
useNativeDriver: true,
}).start()
Animated.timing(this.state.backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start()
}
},
})
}
async componentDidMount() {
try {
// const savedPosition = await AsyncStorage.getItem('@debug_toolkit_position')
// if (savedPosition) {
// const position = JSON.parse(savedPosition)
// // Ensure position is within bounds
// const boundedPosition = {
// x: Math.max(0, Math.min(position.x, screenWidth - IconRadius)),
// y: Math.max(0, Math.min(position.y, screenHeight - IconRadius)),
// }
// this.setState({ lastPosition: boundedPosition })
// this.state.pan.setValue(boundedPosition)
// }
} catch (error) {
console.error('Failed to load debug toolkit position:', error)
}
}
_handlePanResponderGrant = () => {
this.setState({ toFloat: true })
Animated.spring(this.state.scale, {
toValue: 1.2,
friction: 5,
useNativeDriver: true,
}).start()
}
_handlePanResponderMove = (e, gestureState) => {
const { dx, dy } = gestureState
const newX = this.state.lastPosition.x + dx
const newY = this.state.lastPosition.y + dy
// Keep within screen bounds while dragging
const boundedX = Math.max(0, Math.min(newX, screenWidth - IconRadius))
const boundedY = Math.max(0, Math.min(newY, screenHeight - IconRadius))
this.state.pan.setValue({
x: boundedX,
y: boundedY,
})
}
_handlePanResponderRelease = async (e, gestureState) => {
const { dx, dy } = gestureState
// If it's a tap (minimal movement)
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) {
this._togglePanel()
return
}
// Animate button scale back to normal
Animated.spring(this.state.scale, {
toValue: 1,
friction: 5,
useNativeDriver: true,
}).start()
// Calculate final position
let newX = this.state.lastPosition.x + dx
let newY = this.state.lastPosition.y + dy
// Keep within screen bounds
newX = Math.max(0, Math.min(newX, screenWidth - IconRadius))
newY = Math.max(0, Math.min(newY, screenHeight - IconRadius))
const newPosition = { x: newX, y: newY }
// Save position
try {
// await AsyncStorage.setItem('@debug_toolkit_position', JSON.stringify(newPosition))
} catch (error) {
console.error('Failed to save debug toolkit position:', error)
}
this.setState({
lastPosition: newPosition,
toFloat: true,
})
}
_togglePanel = () => {
const { isOpen } = this.state
if (isOpen) {
this._closePanel()
} else {
this._openPanel()
}
}
_openPanel = () => {
this.setState({ isOpen: true, toFloat: false }, () => {
Animated.parallel([
Animated.spring(this.state.panelTranslateY, {
toValue: 0,
friction: 8,
tension: 65,
useNativeDriver: true,
}),
Animated.timing(this.state.backdropOpacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
]).start()
})
}
_closePanel = () => {
this.setState({ isTabMenuVisible: false })
Animated.parallel([
Animated.spring(this.state.panelTranslateY, {
toValue: screenHeight,
friction: 8,
tension: 65,
useNativeDriver: true,
}),
Animated.timing(this.state.backdropOpacity, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start(() => {
this.setState({ isOpen: false, toFloat: true })
})
}
_toggleTabMenu = () => {
this.setState(prevState => ({ isTabMenuVisible: !prevState.isTabMenuVisible }))
}
_handleTabChange = (index) => {
this.setState({
activeTab: index,
isTabMenuVisible: false
}, () => {
// Scroll to make active tab visible
if (this.tabScrollViewRef.current) {
const tabWidth = 80; // Approximate tab width
this.tabScrollViewRef.current.scrollTo({
x: Math.max(0, index * tabWidth - 50), // Center the tab
animated: true
});
}
})
}
renderFloatBtn() {
const { isOpen, toFloat } = this.state
if (!isOpen && toFloat) {
return (
<Animated.View
{...this.gestureResponder.panHandlers}
style={[
styles.floatBtn,
{
transform: [
{ translateX: this.state.pan.x },
{ translateY: this.state.pan.y },
{ scale: this.state.scale },
],
},
]}>
<Pressable onPress={this._togglePanel} style={styles.floatBtnInner}>
<View style={styles.indicatorDot} />
</Pressable>
</Animated.View>
)
}
return null
}
getFeatureTabs() {
const { features } = this.props
if (!features || !features.length) {
return []
}
return features.map((feature) => ({
label: feature.label,
id: feature.name,
}))
}
renderFeatureContent(featureId) {
const { features } = this.props
if (!features || !features.length) {
return <Text style={styles.emptyText}>No features available</Text>
}
const feature = features.find((f) => f.name === featureId)
if (!feature) {
return <Text style={styles.emptyText}>Feature not found</Text>
}
const data = feature.getData()
// Special handling for network logs
if (feature.name === 'network') {
return <SubViewHTTPLogs logs={data} />
}
// Special handling for performance view
// if (feature.name === 'performance') {
// return <SubViewPerformance />
// }
if (feature.name === 'console') {
return <SubViewConsoleLogs logs={data} />
}
if (feature.name === 'zustand') {
return <SubViewZustandLogs logs={data} />
}
if (feature.name === 'navigation') {
return <SubViewNavigationLogs logs={data} />
}
if (feature.name === 'track') {
return <SubViewTrackLogs logs={data} />
}
if (feature.name === 'thirdPartyLibs') {
return <SubViewThirdPartyLibs libraries={data} />
}
// Generic fallback for other feature types
return (
<View style={styles.genericContent}>
<View style={styles.contentHeader}>
<Text style={styles.contentTitle}>{feature.label}</Text>
</View>
<Text style={styles.jsonContent}>{JSON.stringify(data, null, 2)}</Text>
</View>
)
}
renderContent() {
const tabs = this.getFeatureTabs()
const { activeTab } = this.state
if (!tabs.length) {
return <Text style={styles.emptyText}>No debug features available</Text>
}
return this.renderFeatureContent(tabs[activeTab].id)
}
renderTabBar() {
const tabs = this.getFeatureTabs()
const { activeTab } = this.state
if (!tabs.length) return null
return (
<View style={styles.tabBarContainer}>
<View style={styles.tabsRow}>
<ScrollView
ref={this.tabScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.tabsScrollContainer}>
{tabs.map((tab, index) => {
const isActive = index === activeTab
return (
<TouchableOpacity
key={`tab-${tab.id}`}
style={[styles.tab, isActive && styles.activeTab]}
onPress={() => this._handleTabChange(index)}>
<Text
style={[styles.tabText, isActive && styles.activeTabText]}
numberOfLines={1}
ellipsizeMode="tail">
{tab.label}
</Text>
</TouchableOpacity>
)
})}
</ScrollView>
</View>
</View>
)
}
renderPanel() {
const { isOpen, panelTranslateY, backdropOpacity } = this.state
const { clearAll } = this.props
if (!isOpen) {
return null
}
return (
<View style={styles.panelContainer}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}>
<Pressable
style={styles.backdropPressable}
onPress={this._closePanel}
/>
</Animated.View>
<Animated.View
style={[
styles.panel,
{ transform: [{ translateY: panelTranslateY }] },
]}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout
this.setState({ panelLayout: { width, height } })
}}>
<View {...this.panelResponder.panHandlers} style={styles.dragHandle}>
<View style={styles.dragIndicator} />
</View>
<SafeAreaView style={styles.panelContent}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Debug Toolkit</Text>
<View style={styles.headerButtons}>
{clearAll && (
<TouchableOpacity
onPress={() => {
clearAll()
this._closePanel()
}}
style={[styles.clearButton, styles.clearAllButton]}
>
<Text style={styles.clearButtonText}>Clear & Close</Text>
</TouchableOpacity>
)}
<Pressable onPress={this._closePanel} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
{this.renderTabBar()}
<View style={styles.contentContainer}>
{this.renderContent()}
</View>
</SafeAreaView>
</Animated.View>
</View>
)
}
render() {
return (
<View style={styles.container} pointerEvents='box-none'>
{this.renderFloatBtn()}
{this.renderPanel()}
</View>
)
}
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999,
},
floatBtn: {
position: 'absolute',
width: IconRadius,
height: IconRadius,
borderRadius: IconRadius / 2,
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderWidth: 1,
borderColor: DebugColors.blue,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
floatBtnInner: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
indicatorDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: DebugColors.blue,
},
panelContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'flex-end',
},
backdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
},
backdropPressable: {
flex: 1,
},
panel: {
width: '100%',
height: '90%',
backgroundColor: DebugColors.white,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
elevation: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: -5 },
shadowOpacity: 0.3,
shadowRadius: 10.0,
},
dragHandle: {
width: '100%',
height: 30,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: DebugColors.white,
},
dragIndicator: {
width: 40,
height: 5,
borderRadius: 3,
backgroundColor: '#CCCCCC',
},
panelContent: {
flex: 1,
},
contentContainer: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 15,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
backgroundColor: '#f9f9f9',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 1.5,
elevation: 1,
},
headerTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2c3e50',
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
},
clearButton: {
backgroundColor: DebugColors.blue,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 4,
marginRight: 10,
},
clearAllButton: {
backgroundColor: '#3498db',
paddingHorizontal: 14,
paddingVertical: 9,
borderRadius: 6,
marginRight: 12,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.12,
shadowRadius: 1,
elevation: 2,
},
clearButtonText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
closeButton: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#ecf0f1',
alignItems: 'center',
justifyContent: 'center',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 1,
elevation: 1,
},
closeButtonText: {
fontSize: 20,
fontWeight: 'bold',
color: '#34495e',
lineHeight: 20,
},
tabBarContainer: {
borderBottomWidth: 1,
borderBottomColor: DebugColors.border,
backgroundColor: DebugColors.background,
},
tabsRow: {
flexDirection: 'row',
alignItems: 'center',
},
tabsScrollContainer: {
paddingHorizontal: 15,
flexDirection: 'row',
alignItems: 'center',
},
tab: {
paddingVertical: 12,
paddingHorizontal: 8,
marginRight: 4,
alignItems: 'center',
justifyContent: 'center',
minWidth: 50,
},
activeTab: {
borderBottomWidth: 2,
borderBottomColor: DebugColors.blue,
},
tabText: {
fontSize: 13,
color: DebugColors.textLight,
textAlign: 'center',
},
activeTabText: {
color: DebugColors.blue,
fontWeight: '600',
},
emptyText: {
padding: 20,
textAlign: 'center',
color: '#999',
},
genericContent: {
padding: 15,
flex: 1,
},
contentHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
contentTitle: {
fontSize: 16,
fontWeight: 'bold',
color: DebugColors.text,
},
copyButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: DebugColors.blue,
borderRadius: 4,
},
copyButtonText: {
color: '#FFF',
fontSize: 14,
},
jsonContent: {
fontFamily: 'monospace',
fontSize: 12,
color: DebugColors.text,
}
})