From 2111af9a062fbcbadfa51f69f3c5d22e0f452d51 Mon Sep 17 00:00:00 2001 From: M P Date: Wed, 29 Apr 2026 09:19:33 +0200 Subject: [PATCH] feat: Implement a page-wise scroll for BaseListView. Lists like in filebrowser or options can now be scrolled stepwise instead of a smooth scrolling, which is better suited for devices with e-inc screen. fix: BaseListView: add KeyEvent.KEYCODE_PAGE_UP/DOWN to the custom handler --- .../org/coolreader/crengine/BaseActivity.java | 3 + .../org/coolreader/crengine/BaseListView.java | 171 +++++++++++++++--- .../org/coolreader/crengine/DeviceInfo.java | 2 + .../coolreader/crengine/OptionsDialog.java | 4 + .../org/coolreader/crengine/ReaderView.java | 4 + .../src/org/coolreader/crengine/Settings.java | 1 + 6 files changed, 157 insertions(+), 28 deletions(-) diff --git a/android/src/org/coolreader/crengine/BaseActivity.java b/android/src/org/coolreader/crengine/BaseActivity.java index bb6f3fac9..85deee3d8 100644 --- a/android/src/org/coolreader/crengine/BaseActivity.java +++ b/android/src/org/coolreader/crengine/BaseActivity.java @@ -1323,6 +1323,8 @@ public void applyAppSetting(String key, String value) { setScreenBacklightDuration(Utils.parseInt(value, 0)); } else if (key.equals(PROP_NIGHT_MODE)) { setNightMode(flg); + } else if (key.equals(PROP_APP_PAGEWISE_SCROLL)) { + DeviceInfo.PAGEWISE_SCROLLING = flg; } else if (key.equals(PROP_APP_SCREEN_UPDATE_MODE)) { setScreenUpdateMode(EinkScreen.EinkUpdateMode.byCode(Utils.parseInt(value, 0)), getContentView()); } else if (key.equals(PROP_APP_SCREEN_UPDATE_INTERVAL)) { @@ -1957,6 +1959,7 @@ public Properties loadSettings(BaseActivity activity, File file) { props.applyDefault(ReaderView.PROP_APP_SHOW_COVERPAGES, "1"); props.applyDefault(ReaderView.PROP_APP_COVERPAGE_SIZE, "1"); props.applyDefault(ReaderView.PROP_APP_SCREEN_ORIENTATION, "0"); // "0" + props.applyDefault(ReaderView.PROP_APP_PAGEWISE_SCROLL, "0"); props.applyDefault(ReaderView.PROP_CONTROLS_ENABLE_VOLUME_KEYS, "1"); props.applyDefault(ReaderView.PROP_APP_TAP_ZONE_HILIGHT, "0"); props.applyDefault(ReaderView.PROP_APP_BOOK_SORT_ORDER, FileInfo.DEF_SORT_ORDER.name()); diff --git a/android/src/org/coolreader/crengine/BaseListView.java b/android/src/org/coolreader/crengine/BaseListView.java index 94a9de260..ff0f22499 100644 --- a/android/src/org/coolreader/crengine/BaseListView.java +++ b/android/src/org/coolreader/crengine/BaseListView.java @@ -23,17 +23,93 @@ import android.content.Context; import android.graphics.Rect; +import android.util.DisplayMetrics; import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.View; import android.widget.ListView; public class BaseListView extends ListView { - public BaseListView(Context context, boolean fastScrollEnabled) { - super(context); + + //The Values were originally tested on a 440dpi screen, hence the conversion factor in the constructor + private float SWIPE_THRESHOLD_PX = 100f; + + private float NO_MOVE_THRESHOLD_PX = 5f; + + private float touchStartY = 0f; + + + public BaseListView(Context context, boolean fastScrollEnabled) { + super(context); setFocusable(true); setFocusableInTouchMode(true); setFastScrollEnabled(fastScrollEnabled); - } + DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + //The Values were originally tested on a 440dpi screen, hence the conversion factor + final float dpiDependentFactor = displayMetrics.ydpi / 440; + SWIPE_THRESHOLD_PX = Math.round(dpiDependentFactor * SWIPE_THRESHOLD_PX); + NO_MOVE_THRESHOLD_PX = Math.round(dpiDependentFactor * NO_MOVE_THRESHOLD_PX); + } + + /** This method is designed to allow the ListView to scroll page-wise. + * FileBrowser.MyGestureListener or any other listener should not be impaired by this. + * */ + + @Override + public boolean onTouchEvent(MotionEvent ev) { + boolean returnValue = false; + + if (DeviceInfo.PAGEWISE_SCROLLING) { + switch (ev.getAction()) { + //Start registering the events. + //Allow ACTION_DOWN to be passed normally to the ListView + //to keep the click/longclick functionality + case MotionEvent.ACTION_DOWN: + touchStartY = ev.getY(); + returnValue = super.onTouchEvent(ev); + break; + + case MotionEvent.ACTION_MOVE: + /** + * Interrupt normal scrolling, when NO_MOVE_THRESHOLD_PX is reached. + * Once the movement starts, claim the following events. + * NO_MOVE_THRESHOLD_PX is there to allow for some little movement when + * performing a click. + */ + + //TODO: NO_MOVE_THRESHOLD_PX may need some testing and fine tuning + returnValue = true; + if (Math.abs(touchStartY - ev.getY()) >= NO_MOVE_THRESHOLD_PX) { + ev.setAction(MotionEvent.ACTION_CANCEL); + } + + super.onTouchEvent(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + /** + * Scroll page-wise when SWIPE_THRESHOLD_PX is reached. + */ + //TODO maybe tweak SWIPE_THRESHOLD_PX too, current values are rather experimental + super.onTouchEvent(ev); + + if (Math.abs(touchStartY - ev.getY()) >= SWIPE_THRESHOLD_PX) { //delta is positive = swiped up (next page) + if (touchStartY - ev.getY() > 0) { + scrollPage(1); + } else { + scrollPage(-1); + } + returnValue = true; + } + break; + } + } else { + returnValue = super.onTouchEvent(ev); + } + return returnValue; + } + @Override public boolean onKeyDown(int keyCode, KeyEvent event) { @@ -46,36 +122,75 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { else if (keyCode == ReaderView.SONY_DPAD_LEFT_SCANCODE || keyCode == ReaderView.SONY_DPAD_UP_SCANCODE || keyCode==KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_LEFT ) dir = -1; //} else { - else if (keyCode == KeyEvent.KEYCODE_8 || keyCode == ReaderView.NOOK_KEY_NEXT_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == ReaderView.NOOK_KEY_SHIFT_DOWN) + else if (keyCode == KeyEvent.KEYCODE_8 || keyCode == ReaderView.NOOK_KEY_NEXT_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == ReaderView.NOOK_KEY_SHIFT_DOWN || keyCode == KeyEvent.KEYCODE_PAGE_DOWN) dir = 1; - else if (keyCode == KeyEvent.KEYCODE_2 || keyCode == ReaderView.NOOK_KEY_PREV_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == ReaderView.NOOK_KEY_SHIFT_UP) + else if (keyCode == KeyEvent.KEYCODE_2 || keyCode == ReaderView.NOOK_KEY_PREV_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == ReaderView.NOOK_KEY_SHIFT_UP || keyCode == KeyEvent.KEYCODE_PAGE_UP) dir = -1; //} - if (dir != 0) { - int firstPos = getFirstVisiblePosition(); - int lastPos = getLastVisiblePosition(); - int count = getCount(); - - int delta = 1; - if( dir < 0 ) { - View v = getChildAt(0); - if( v != null ) { - int fh = v.getHeight(); - Rect r = new Rect(0,0,v.getWidth(),fh); - getChildVisibleRect(v, r, null); - delta = (r.height() < fh) ? 1 : 0; - } - } - - int nextPos = ( dir > 0 ) ? Math.min(lastPos + 1, count - 1) : Math.max(0, firstPos - (lastPos - firstPos) + delta); - - // Log.w("CoolReader", "first =" + firstPos + " last = " + lastPos + " next = " + nextPos + " count = " + count); - - setSelection(nextPos); - clearFocus(); - + if (dir != 0) { + scrollPage(dir); return true; } return super.onKeyDown(keyCode, event); } + + /** Scroll the ListView page-wise. + * Logic is: + * 1. Scrolling down - the last (bottom) not fully visible view should be the first + * fully displayed view on the next page. + * 2. Scrolling up - the first (top) not fully visible view should be the last + * fully displayed view on the previous page + * */ + private void scrollPage(int dir) { + int firstPos = getFirstVisiblePosition(); + int lastPos = getLastVisiblePosition(); + int count = getCount(); + + int delta = 0; + int newTopItem = 0; + if( dir < 0 ) { + //Check if the currently displayed top childview is fully visible or incomplete + View v = getChildAt(0); + if( v != null ) { + int fh = v.getHeight(); + Rect r = new Rect(0,0,v.getWidth(),fh); + getChildVisibleRect(v, r, null); + delta = (r.height() < fh) ? 1 : 0; + } + + //Scrolling up, account for when the items can have different heights + // (e.g. by using a larger font, breaking into more lines, etc.) + newTopItem = Math.max(0, firstPos - 1 + delta); + Rect visibleListViewRect = new Rect(); + this.getGlobalVisibleRect(visibleListViewRect); + int visibleHeight = visibleListViewRect.height(); + int usableHeight = 0; + for (int i = newTopItem; i >= 0; i--) { + //getchildAt() returns null if the item is not visible. + //Ask adapter directly to get the children parameters + View view = getAdapter().getView(i, null, this); + /** + * Because normally ListViews are measured lazy, view.getMeasuredHeight() can return 0 + * when the view hasn't gone through a layout pass yet. + * Inflate and measure each item view, forcing a layout pass. + * MeasureSpec.UNSPECIFIED for height lets the view report its natural height, + * and MeasureSpec.EXACTLY for width constrains it to the real ListView width + * so it wraps correctly. + */ + view.measure( View.MeasureSpec.makeMeasureSpec(this.getWidth(), MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + usableHeight += view.getMeasuredHeight() + getDividerHeight(); + if (usableHeight > visibleHeight) { + //use last view that fits fully into the next page as top view + break; + } + newTopItem = i; + } + } + + int nextPos = ( dir > 0 ) ? Math.min(lastPos, count - 1) : Math.max(0, newTopItem) ; + + setSelection(nextPos); + clearFocus(); + } } diff --git a/android/src/org/coolreader/crengine/DeviceInfo.java b/android/src/org/coolreader/crengine/DeviceInfo.java index eef5892a3..7a4ce5a5f 100644 --- a/android/src/org/coolreader/crengine/DeviceInfo.java +++ b/android/src/org/coolreader/crengine/DeviceInfo.java @@ -76,6 +76,8 @@ public class DeviceInfo { public final static Integer DEF_FONT_SIZE; public final static boolean ONE_COLUMN_IN_LANDSCAPE; + public static boolean PAGEWISE_SCROLLING = false; + // minimal screen backlight level percent for different devices private static final String[] MIN_SCREEN_BRIGHTNESS_DB = { "LGE;LG-P500", "6", // LG Optimus One diff --git a/android/src/org/coolreader/crengine/OptionsDialog.java b/android/src/org/coolreader/crengine/OptionsDialog.java index f3d3b8552..728d1a02c 100644 --- a/android/src/org/coolreader/crengine/OptionsDialog.java +++ b/android/src/org/coolreader/crengine/OptionsDialog.java @@ -2905,6 +2905,10 @@ private void setupReaderOptions() mBounceProtectionOption = new ListOption(this, getString(R.string.options_controls_bonce_protection), PROP_APP_BOUNCE_TAP_INTERVAL).add(mBounceProtectionValues, mBounceProtectionTitles).setDefaultValue(String.valueOf(150)); mOptionsControls.add(mBounceProtectionOption); doubleTapOnChange.run(); + Runnable changehandlerPagewiseScroll = () -> { + DeviceInfo.PAGEWISE_SCROLLING = mProperties.getBool(PROP_APP_PAGEWISE_SCROLL, false); + }; + mOptionsControls.add(new BoolOption(this, "Page-wise scroll", PROP_APP_PAGEWISE_SCROLL).setDefaultValue("0").setOnChangeHandler(changehandlerPagewiseScroll)); if ( !DeviceInfo.EINK_SCREEN ) mOptionsControls.add(new BoolOption(this, getString(R.string.options_controls_enable_volume_keys), PROP_CONTROLS_ENABLE_VOLUME_KEYS).setDefaultValue("1")); mOptionsControls.add(new BoolOption(this, getString(R.string.options_app_tapzone_hilite), PROP_APP_TAP_ZONE_HILIGHT).setDefaultValue("0").setIconIdByAttr(R.attr.cr3_option_touch_drawable, R.drawable.cr3_option_touch)); diff --git a/android/src/org/coolreader/crengine/ReaderView.java b/android/src/org/coolreader/crengine/ReaderView.java index 52ec2a0ae..8ab4d751b 100644 --- a/android/src/org/coolreader/crengine/ReaderView.java +++ b/android/src/org/coolreader/crengine/ReaderView.java @@ -2874,6 +2874,7 @@ public boolean showManual() { } private boolean hiliteTapZoneOnTap = false; + private boolean enablePagewiseScroll = false; private boolean enableVolumeKeys = true; static private final int DEF_PAGE_FLIP_MS = 300; @@ -2907,6 +2908,8 @@ public void applyAppSetting(String key, String value) { pageFlipAnimationSpeedMs = pageFlipAnimationMode != PAGE_ANIMATION_NONE ? DEF_PAGE_FLIP_MS : 0; } else if (PROP_CONTROLS_ENABLE_VOLUME_KEYS.equals(key)) { enableVolumeKeys = flg; + } else if (PROP_APP_PAGEWISE_SCROLL.equals(key)) { + enablePagewiseScroll = flg; } else if (PROP_APP_SELECTION_ACTION.equals(key)) { mSelectionAction = Utils.parseInt(value, SELECTION_ACTION_TOOLBAR); } else if (PROP_APP_MULTI_SELECTION_ACTION.equals(key)) { @@ -2948,6 +2951,7 @@ public void setAppSettings(Properties newSettings, Properties oldSettings) { || PROP_APP_SCREEN_BACKLIGHT_LOCK.equals(key) || PROP_APP_TAP_ZONE_HILIGHT.equals(key) || PROP_APP_DICTIONARY.equals(key) + || PROP_APP_PAGEWISE_SCROLL.equals(key) || PROP_APP_DOUBLE_TAP_SELECTION.equals(key) || PROP_APP_BOUNCE_TAP_INTERVAL.equals(key) || PROP_APP_FLICK_BACKLIGHT_CONTROL.equals(key) diff --git a/android/src/org/coolreader/crengine/Settings.java b/android/src/org/coolreader/crengine/Settings.java index 32c1a2650..6c9a22e09 100644 --- a/android/src/org/coolreader/crengine/Settings.java +++ b/android/src/org/coolreader/crengine/Settings.java @@ -151,6 +151,7 @@ public interface Settings { public static final String PROP_APP_SCREEN_BACKLIGHT_DAY ="app.screen.backlight.day"; public static final String PROP_APP_SCREEN_BACKLIGHT_NIGHT ="app.screen.backlight.night"; public static final String PROP_APP_DOUBLE_TAP_SELECTION ="app.controls.doubletap.selection"; + public static final String PROP_APP_PAGEWISE_SCROLL = "app.controls.pagewise.scroll"; public static final String PROP_APP_BOUNCE_TAP_INTERVAL ="app.controls.bounce.interval"; public static final String PROP_APP_TAP_ZONE_ACTIONS_TAP ="app.tapzone.action.tap"; public static final String PROP_APP_KEY_ACTIONS_PRESS ="app.key.action.press";