Hi everyone,
I've been working on a couple of UX improvements for the Android app that I think would make Joplin feel significantly more polished, especially for users who switched from Obsidian or use Android gesture navigation. I'd love to hear feedback before submitting a proper PR.
1. Push-content side menu (instead of overlay)
Currently the left sidebar slides over the content. Most modern note-taking apps (Obsidian, Gmail, Notion) push the content to the right instead — the menu and content move together as one unit.
Current behavior: sidebar overlays the note list
Proposed behavior: sidebar pushes the note list to the right
The implementation uses react-native-gesture-handler with direct Animated.Value.setValue() for real-time finger tracking and Animated.spring() on release:
// menuX: -menuWidth (closed) → 0 (open)
// contentX mirrors it: 0 → menuWidth
const contentTranslateX = menuX.interpolate({
inputRange: [-menuWidth, 0],
outputRange: [0, menuWidth],
extrapolate: 'clamp',
});
// Spring config (Material Design feel)
const SPRING_CONFIG = {
useNativeDriver: true,
damping: 32,
stiffness: 300,
mass: 1,
overshootClamping: true,
};
The result feels native — the menu literally follows your finger, then snaps open/closed on release based on velocity or distance.
2. Obsidian-style notes list inside the sidebar
Right now the sidebar only shows folders. In Obsidian, tapping a folder reveals its notes inline — you can navigate between notes without ever closing the panel.
Proposed change: show the current folder's notes below the folder tree, updating automatically when you switch folders. Tapping a note opens it without closing the sidebar.
// In side-menu-content.tsx — connected to Redux state
notes: state.notes, // current folder's notes (already in store)
selectedNoteId: state.selectedNoteIds?.[0] ?? null,
// Render notes below folder tree
{props.notes.length > 0 && props.notes.map(note => (
<NoteItem
key={note.id}
note={note}
selected={note.id === props.selectedNoteId}
themeId={props.themeId}
onPress={note_press} // opens note, menu stays open
/>
))}
No new data fetching needed — state.notes is already populated by the existing reducer.
3. Configurable swipe edge width — important for gesture navigation users
This is the part I think deserves most attention. On Android phones with gesture navigation (no back button), the system back gesture also lives on the left edge of the screen — the same place Joplin's menu swipe originates. This causes constant false triggers.
The fix: expose the swipe edge zone as a user setting, and add velocity + distance thresholds so slow accidental drags don't open the menu.
New settings in builtInMetadata.ts:
'ui.sideMenuSwipeWidth': {
value: 80,
type: SettingItemType.Int,
public: true,
appTypes: [AppType.Mobile],
section: 'appearance',
label: () => _('Side menu swipe area width (pixels)'),
description: () => _('Width of the screen edge area that triggers the side menu (25–200px). Increase to 120+ if using Android gesture navigation.'),
minimum: 25,
maximum: 200,
step: 10,
storage: SettingStorage.File,
isGlobal: true,
},
'ui.sideMenuSwipeVelocityThreshold': {
value: 500,
type: SettingItemType.Int,
public: true,
appTypes: [AppType.Mobile],
section: 'appearance',
label: () => _('Side menu swipe velocity threshold (px/s)'),
description: () => _('Minimum swipe speed required to open the side menu. Higher values reduce false triggers.'),
minimum: 100,
maximum: 2000,
step: 50,
storage: SettingStorage.File,
isGlobal: true,
},
'ui.sideMenuSwipeDistanceThreshold': {
value: 60,
type: SettingItemType.Int,
public: true,
appTypes: [AppType.Mobile],
section: 'appearance',
label: () => _('Side menu swipe distance threshold (px)'),
minimum: 20,
maximum: 300,
step: 10,
storage: SettingStorage.File,
isGlobal: true,
},
Gesture detection logic — the key part that prevents conflicts with Android system gestures:
const panGesture = Gesture.Pan()
.onStart(() => {
const fromEdge = !isOpen && touchStartX.current <= swipeEdgeWidth;
const canClose = isOpen;
gestureValid.current = fromEdge || canClose;
})
.onUpdate((e) => {
if (!gestureValid.current) return;
// Cancel if vertical scroll dominates
const absX = Math.abs(e.translationX);
const absY = Math.abs(e.translationY);
if (absY > absX * 1.8 && absX < distanceThreshold * 0.3) {
gestureValid.current = false;
return;
}
// Direct finger tracking
const clampedX = Math.max(-menuWidth, Math.min(0,
gestureStartMenuX.current + e.translationX
));
menuX.setValue(clampedX);
})
.onEnd((e) => {
const vx = e.velocityX;
const dx = currentMenuX.current - gestureStartMenuX.current;
if (vx > velocityThreshold || dx > distanceThreshold) snapOpen();
else if (vx < -velocityThreshold || dx < -distanceThreshold) snapClose();
else currentMenuX.current >= -menuWidth / 2 ? snapOpen() : snapClose();
})
// Critical — prevents conflict with Android back gesture and ScrollView
.activeOffsetX([-15, 15])
.failOffsetY([-22, 22]);
Recommended settings for gesture navigation: swipeWidth: 100–120px, velocityThreshold: 600+
Recommended settings for button navigation: swipeWidth: 60–80px, velocityThreshold: 400
4. No auto-focus / keyboard popup in view mode
Small but annoying: opening a note in view mode currently triggers keyboard popup on some devices. Three-line fix in Note.tsx:
// 1. scheduleFocusUpdate — skip entirely in view mode
public scheduleFocusUpdate() {
if (this.state.mode === 'view') return;
// ...
}
// 2. Multiline title recalc — guard against layout-triggered focus
if (prevState.multiline !== this.state.multiline
&& this.titleTextFieldRef.current
&& this.state.mode === 'edit') { // ← added
focus('Note::focusUpdate::title', this.titleTextFieldRef.current);
}
// 3. Title TextInput — physically non-editable in view mode
editable={this.state.mode === 'edit' && !this.state.readOnly}
Without fix #3, Android can still focus a TextInput programmatically even when you don't intend it. editable={false} blocks that at the OS level.