pythonnative 0.22.1__py3-none-any.whl → 0.23.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. pythonnative/__init__.py +16 -2
  2. pythonnative/appearance.py +124 -0
  3. pythonnative/components.py +708 -38
  4. pythonnative/hooks.py +41 -0
  5. pythonnative/images.py +196 -0
  6. pythonnative/mutations.py +3 -12
  7. pythonnative/native_views/__init__.py +1 -7
  8. pythonnative/native_views/android.py +573 -45
  9. pythonnative/native_views/desktop.py +78 -19
  10. pythonnative/native_views/ios.py +739 -51
  11. pythonnative/preview.py +31 -0
  12. pythonnative/project/config.py +1 -9
  13. pythonnative/screen.py +85 -0
  14. pythonnative/style.py +65 -6
  15. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  16. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  17. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  18. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  19. pythonnative/virtual_rows.py +137 -0
  20. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/METADATA +1 -1
  21. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/RECORD +25 -19
  22. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
  23. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
  24. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
  25. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
pythonnative/preview.py CHANGED
@@ -43,6 +43,36 @@ _POLL_INTERVAL_MS = 16
43
43
  _WATCH_INTERVAL_S = 0.4
44
44
 
45
45
 
46
+ def _publish_desktop_color_scheme() -> None:
47
+ """Publish the host OS appearance so `use_color_scheme` works in preview.
48
+
49
+ ``PN_COLOR_SCHEME=light|dark`` forces a value (handy for testing
50
+ both appearances); otherwise macOS is asked via ``defaults read``
51
+ (the key only exists when dark mode is on). Other platforms default
52
+ to light.
53
+ """
54
+ from . import appearance
55
+
56
+ forced = os.environ.get("PN_COLOR_SCHEME")
57
+ if forced in ("light", "dark"):
58
+ appearance.set_system_color_scheme(forced)
59
+ return
60
+ if sys.platform == "darwin":
61
+ import subprocess
62
+
63
+ try:
64
+ result = subprocess.run(
65
+ ["defaults", "read", "-g", "AppleInterfaceStyle"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=2,
69
+ )
70
+ is_dark = result.returncode == 0 and result.stdout.strip() == "Dark"
71
+ appearance.set_system_color_scheme("dark" if is_dark else "light")
72
+ except Exception:
73
+ pass
74
+
75
+
46
76
  class DesktopApp:
47
77
  """Navigation-stack controller for the desktop preview window.
48
78
 
@@ -359,6 +389,7 @@ def run_preview(
359
389
  )
360
390
 
361
391
  root_dir, watched = _resolve_paths(component_path, project_root, watch_dir)
392
+ _publish_desktop_color_scheme()
362
393
 
363
394
  root = tk.Tk()
364
395
  root.title(title)
@@ -321,15 +321,7 @@ class AppConfig:
321
321
  root = Path(project_root) if project_root is not None else Path.cwd()
322
322
  config_path = root / CONFIG_FILENAME
323
323
  if not config_path.is_file():
324
- legacy = root / "pythonnative.json"
325
- hint = ""
326
- if legacy.is_file():
327
- hint = (
328
- "\nFound a legacy 'pythonnative.json'. PythonNative now uses "
329
- "'pythonnative.toml'.\nRun 'pn init --force' to scaffold one, then "
330
- "port your settings over."
331
- )
332
- raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.{hint}")
324
+ raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.")
333
325
  try:
334
326
  with open(config_path, "rb") as handle:
335
327
  data = _toml.load(handle)
pythonnative/screen.py CHANGED
@@ -678,6 +678,65 @@ if IS_ANDROID:
678
678
  bottom_px / density,
679
679
  right_px / density,
680
680
  )
681
+ # IME (soft keyboard) insets: API 30+ exposes them through
682
+ # the typed getInsets API. The keyboard overlaps the system
683
+ # navigation bar, so the visible keyboard height is the IME
684
+ # inset minus the permanent bottom bar inset. Older API
685
+ # levels don't report IME insets here; those devices rely
686
+ # on ``adjustResize`` shrinking the window (the viewport
687
+ # push handles the layout) and the height stays 0.
688
+ try:
689
+ ime = insets_obj.getInsets(Type.ime())
690
+ ime_px = max(0, int(ime.bottom) - bottom_px)
691
+ platform_metrics.set_keyboard_height(ime_px / density)
692
+ except Exception:
693
+ pass
694
+ except Exception:
695
+ pass
696
+
697
+ def _android_publish_color_scheme(activity: Any) -> None:
698
+ """Read the system night mode from the activity and publish it.
699
+
700
+ Android delivers appearance changes as a configuration change,
701
+ which recreates the activity by default, so publishing on
702
+ ``on_create`` / ``on_resume`` covers both the initial value and
703
+ subsequent flips.
704
+ """
705
+ try:
706
+ from . import appearance
707
+
708
+ Configuration = jclass("android.content.res.Configuration")
709
+ ui_mode = int(activity.getResources().getConfiguration().uiMode)
710
+ night = ui_mode & int(Configuration.UI_MODE_NIGHT_MASK)
711
+ is_dark = night == int(Configuration.UI_MODE_NIGHT_YES)
712
+ appearance.set_system_color_scheme("dark" if is_dark else "light")
713
+ except Exception:
714
+ pass
715
+
716
+ def _android_register_insets_listener(host: Any, view: Any) -> None:
717
+ """Re-publish insets whenever the window's insets change.
718
+
719
+ The ``OnLayoutChangeListener`` in ``_android_register_layout_listener``
720
+ only fires when the container is re-measured, which covers
721
+ ``adjustResize`` keyboards but not inset-only changes (e.g. an
722
+ edge-to-edge window where the IME floats over the content).
723
+ ``setOnApplyWindowInsetsListener`` fires on every insets pass,
724
+ so keyboard show/hide reaches ``platform_metrics`` immediately.
725
+ """
726
+ try:
727
+ View = jclass("android.view.View")
728
+
729
+ class _PNInsetsListener(dynamic_proxy(View.OnApplyWindowInsetsListener)): # type: ignore[misc]
730
+ def onApplyWindowInsets(self, v: Any, insets: Any) -> Any:
731
+ try:
732
+ _android_publish_window_insets(v)
733
+ except Exception:
734
+ pass
735
+ return insets
736
+
737
+ listener = _PNInsetsListener()
738
+ view.setOnApplyWindowInsetsListener(listener)
739
+ host._insets_listener = listener # retain to prevent GC
681
740
  except Exception:
682
741
  pass
683
742
 
@@ -761,12 +820,14 @@ if IS_ANDROID:
761
820
  _init_host_common(self, component_path, component_func)
762
821
 
763
822
  def on_create(self) -> None:
823
+ _android_publish_color_scheme(self.native_instance)
764
824
  _on_create(self)
765
825
 
766
826
  def on_start(self) -> None:
767
827
  pass
768
828
 
769
829
  def on_resume(self) -> None:
830
+ _android_publish_color_scheme(self.native_instance)
770
831
  _set_host_focused(self, True)
771
832
 
772
833
  def on_layout(self) -> None:
@@ -873,6 +934,7 @@ if IS_ANDROID:
873
934
 
874
935
  if container is not None:
875
936
  _android_register_layout_listener(self, container)
937
+ _android_register_insets_listener(self, container)
876
938
  _android_push_initial_viewport(self, container)
877
939
 
878
940
  def _detach_root(self, native_view: Any) -> None:
@@ -1128,6 +1190,24 @@ else:
1128
1190
  except Exception:
1129
1191
  pass
1130
1192
 
1193
+ def _ios_publish_color_scheme() -> None:
1194
+ """Read the current UIKit interface style and publish it.
1195
+
1196
+ ``UITraitCollection.currentTraitCollection`` reflects the
1197
+ active appearance; ``userInterfaceStyle`` is 1 for light and
1198
+ 2 for dark (0 = unspecified, treated as light). Called from
1199
+ the lifecycle callbacks that fire on appearance flips
1200
+ (``viewDidLayoutSubviews`` / ``viewDidAppear``).
1201
+ """
1202
+ try:
1203
+ from . import appearance
1204
+
1205
+ UITraitCollection = ObjCClass("UITraitCollection")
1206
+ style_value = int(UITraitCollection.currentTraitCollection.userInterfaceStyle)
1207
+ appearance.set_system_color_scheme("dark" if style_value == 2 else "light")
1208
+ except Exception:
1209
+ pass
1210
+
1131
1211
  def _ios_register_screen(vc_instance: Any, host_obj: Any) -> None:
1132
1212
  ptr = _objc_addr(vc_instance)
1133
1213
  if ptr is None:
@@ -1273,6 +1353,7 @@ else:
1273
1353
  _ios_register_screen(self.native_instance, self)
1274
1354
 
1275
1355
  def on_create(self) -> None:
1356
+ _ios_publish_color_scheme()
1276
1357
  _on_create(self)
1277
1358
 
1278
1359
  def on_start(self) -> None:
@@ -1483,6 +1564,9 @@ else:
1483
1564
  # insets are now valid (initial layout, rotation,
1484
1565
  # multitasking, …). Re-sync the root frame and push the
1485
1566
  # viewport so the layout engine matches the visible area.
1567
+ # Appearance flips also trigger a layout pass, so the
1568
+ # color scheme is re-published here too.
1569
+ _ios_publish_color_scheme()
1486
1570
  if self._root_native_view is None:
1487
1571
  _log_pn("on_layout: no root_native_view yet, skipping")
1488
1572
  return
@@ -1494,6 +1578,7 @@ else:
1494
1578
  # ``viewDidAppear`` always follows ``viewDidLayoutSubviews``,
1495
1579
  # but trigger one extra sync here for safety in case a
1496
1580
  # template overrides the layout call without forwarding.
1581
+ _ios_publish_color_scheme()
1497
1582
  _set_host_focused(self, True)
1498
1583
  if self._root_native_view is None:
1499
1584
  _log_pn("on_resume: no root_native_view yet, skipping")
pythonnative/style.py CHANGED
@@ -36,7 +36,7 @@ Example:
36
36
 
37
37
  from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union
38
38
 
39
- from .hooks import Context, create_context
39
+ from .hooks import Context, create_context, use_color_scheme, use_context
40
40
 
41
41
  # ======================================================================
42
42
  # Atomic value types
@@ -283,6 +283,14 @@ class Style(TypedDict, total=False):
283
283
  # --- Visual: borders ---
284
284
  border_width: float
285
285
  border_radius: float
286
+ border_top_width: float
287
+ border_right_width: float
288
+ border_bottom_width: float
289
+ border_left_width: float
290
+ border_top_color: Color
291
+ border_right_color: Color
292
+ border_bottom_color: Color
293
+ border_left_color: Color
286
294
 
287
295
  # --- Visual: typography ---
288
296
  font_size: float
@@ -481,6 +489,7 @@ DEFAULT_LIGHT_THEME: Dict[str, Any] = {
481
489
  "spacing_large": 16,
482
490
  "border_radius": 8,
483
491
  }
492
+ """Built-in light theme selected by [`use_theme`][pythonnative.use_theme]."""
484
493
 
485
494
  DEFAULT_DARK_THEME: Dict[str, Any] = {
486
495
  "primary_color": "#0A84FF",
@@ -500,17 +509,65 @@ DEFAULT_DARK_THEME: Dict[str, Any] = {
500
509
  "spacing_large": 16,
501
510
  "border_radius": 8,
502
511
  }
512
+ """Built-in dark theme selected by [`use_theme`][pythonnative.use_theme]."""
503
513
 
504
- ThemeContext: Context = create_context(DEFAULT_LIGHT_THEME)
505
- """Default theme context populated with `DEFAULT_LIGHT_THEME`.
514
+ _FOLLOW_SYSTEM_THEME = object()
515
+ """Sentinel default for `ThemeContext`: resolve from the color scheme."""
506
516
 
507
- Wrap a subtree in
517
+ ThemeContext: Context = create_context(_FOLLOW_SYSTEM_THEME)
518
+ """Theme context that follows the system color scheme by default.
519
+
520
+ Without a provider, [`use_theme`][pythonnative.use_theme] resolves to
521
+ [`DEFAULT_LIGHT_THEME`][pythonnative.style.DEFAULT_LIGHT_THEME] or
522
+ [`DEFAULT_DARK_THEME`][pythonnative.style.DEFAULT_DARK_THEME] based on
523
+ the current appearance. Wrap a subtree in
508
524
  [`Provider(ThemeContext, my_theme, ...)`][pythonnative.Provider] to
509
- override the theme for that subtree, then read it inside descendants
510
- via [`use_context(ThemeContext)`][pythonnative.use_context].
525
+ pin an explicit theme for that subtree, then read it inside
526
+ descendants via [`use_theme`][pythonnative.use_theme] (or
527
+ [`use_context(ThemeContext)`][pythonnative.use_context]).
511
528
  """
512
529
 
513
530
 
531
+ def default_theme(scheme: str) -> Dict[str, Any]:
532
+ """Return the built-in theme dict for ``scheme`` (``"light"`` / ``"dark"``)."""
533
+ return DEFAULT_DARK_THEME if scheme == "dark" else DEFAULT_LIGHT_THEME
534
+
535
+
536
+ def use_theme() -> Dict[str, Any]:
537
+ """Return the active theme, following the system appearance by default.
538
+
539
+ If an ancestor mounted a ``Provider(ThemeContext, ...)``, that
540
+ theme is returned as-is. Otherwise the built-in light or dark
541
+ theme is selected from the effective color scheme (via
542
+ [`use_color_scheme`][pythonnative.use_color_scheme], so the
543
+ component re-renders when the system appearance flips).
544
+
545
+ Returns:
546
+ The active theme dict.
547
+
548
+ Raises:
549
+ RuntimeError: If called outside a `@component` function.
550
+
551
+ Example:
552
+ ```python
553
+ import pythonnative as pn
554
+
555
+ @pn.component
556
+ def Card():
557
+ theme = pn.use_theme()
558
+ return pn.View(
559
+ pn.Text("Hello", style=pn.style(color=theme["text_color"])),
560
+ style=pn.style(background_color=theme["surface_color"]),
561
+ )
562
+ ```
563
+ """
564
+ scheme = use_color_scheme()
565
+ theme = use_context(ThemeContext)
566
+ if theme is _FOLLOW_SYSTEM_THEME:
567
+ return default_theme(scheme)
568
+ return theme
569
+
570
+
514
571
  __all__ = [
515
572
  "AlignItems",
516
573
  "AlignSelf",
@@ -543,6 +600,8 @@ __all__ = [
543
600
  "TransformScaleY",
544
601
  "TransformSpec",
545
602
  "TransformTranslate",
603
+ "default_theme",
546
604
  "resolve_style",
547
605
  "style",
606
+ "use_theme",
548
607
  ]
@@ -14,7 +14,8 @@
14
14
  tools:targetApi="31">
15
15
  <activity
16
16
  android:name=".MainActivity"
17
- android:exported="true">
17
+ android:exported="true"
18
+ android:windowSoftInputMode="adjustResize">
18
19
  <intent-filter>
19
20
  <action android:name="android.intent.action.MAIN" />
20
21
 
@@ -0,0 +1,47 @@
1
+ package com.pythonnative.android_template
2
+
3
+ import android.os.Build
4
+ import android.view.View
5
+ import android.view.accessibility.AccessibilityNodeInfo
6
+
7
+ /**
8
+ * Accessibility delegate that exposes PythonNative's `test_id` and
9
+ * `accessibility_state` props through the Android accessibility tree.
10
+ *
11
+ * `test_id` is surfaced as the node's view-id resource name, which
12
+ * UI Automator-based tools (Maestro, UiAutomator2, Appium) match as
13
+ * `resource-id`. State flags map onto the closest
14
+ * [AccessibilityNodeInfo] equivalents so TalkBack announces them.
15
+ *
16
+ * Instances are created and configured from Python (the Android view
17
+ * handlers); fields left null are not applied.
18
+ */
19
+ class PNAccessibilityDelegate : View.AccessibilityDelegate() {
20
+ var testId: String? = null
21
+ var stateDisabled: Boolean? = null
22
+ var stateSelected: Boolean? = null
23
+ var stateChecked: Boolean? = null
24
+ var stateBusy: Boolean? = null
25
+ var stateExpanded: Boolean? = null
26
+
27
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
28
+ super.onInitializeAccessibilityNodeInfo(host, info)
29
+ testId?.let { info.viewIdResourceName = it }
30
+ stateDisabled?.let { info.isEnabled = !it }
31
+ stateSelected?.let { info.isSelected = it }
32
+ stateChecked?.let {
33
+ info.isCheckable = true
34
+ info.isChecked = it
35
+ }
36
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
37
+ val states = mutableListOf<String>()
38
+ if (stateBusy == true) states.add("busy")
39
+ when (stateExpanded) {
40
+ true -> states.add("expanded")
41
+ false -> states.add("collapsed")
42
+ null -> {}
43
+ }
44
+ if (states.isNotEmpty()) info.stateDescription = states.joinToString(", ")
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,67 @@
1
+ package com.pythonnative.android_template
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.ColorFilter
5
+ import android.graphics.Paint
6
+ import android.graphics.PixelFormat
7
+ import android.graphics.RectF
8
+ import android.graphics.drawable.Drawable
9
+
10
+ /**
11
+ * Background drawable that renders an optional rounded fill plus four
12
+ * independent border strips. Used for PythonNative's
13
+ * `border_<side>_width` / `border_<side>_color` style props, which
14
+ * `GradientDrawable`'s single uniform stroke cannot express.
15
+ *
16
+ * Widths are in pixels; sides are ordered left, top, right, bottom.
17
+ */
18
+ class PNBorderDrawable(
19
+ private val hasBackground: Boolean,
20
+ private val backgroundColor: Int,
21
+ private val cornerRadius: Float,
22
+ private val widths: FloatArray,
23
+ private val colors: IntArray,
24
+ ) : Drawable() {
25
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
26
+
27
+ override fun draw(canvas: Canvas) {
28
+ val b = RectF(bounds)
29
+ if (hasBackground) {
30
+ paint.style = Paint.Style.FILL
31
+ paint.color = backgroundColor
32
+ if (cornerRadius > 0f) {
33
+ canvas.drawRoundRect(b, cornerRadius, cornerRadius, paint)
34
+ } else {
35
+ canvas.drawRect(b, paint)
36
+ }
37
+ }
38
+ paint.style = Paint.Style.FILL
39
+ if (widths[0] > 0f) {
40
+ paint.color = colors[0]
41
+ canvas.drawRect(b.left, b.top, b.left + widths[0], b.bottom, paint)
42
+ }
43
+ if (widths[1] > 0f) {
44
+ paint.color = colors[1]
45
+ canvas.drawRect(b.left, b.top, b.right, b.top + widths[1], paint)
46
+ }
47
+ if (widths[2] > 0f) {
48
+ paint.color = colors[2]
49
+ canvas.drawRect(b.right - widths[2], b.top, b.right, b.bottom, paint)
50
+ }
51
+ if (widths[3] > 0f) {
52
+ paint.color = colors[3]
53
+ canvas.drawRect(b.left, b.bottom - widths[3], b.right, b.bottom, paint)
54
+ }
55
+ }
56
+
57
+ override fun setAlpha(alpha: Int) {
58
+ paint.alpha = alpha
59
+ }
60
+
61
+ override fun setColorFilter(colorFilter: ColorFilter?) {
62
+ paint.colorFilter = colorFilter
63
+ }
64
+
65
+ @Deprecated("Deprecated in Java")
66
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
67
+ }
@@ -0,0 +1,172 @@
1
+ package com.pythonnative.android_template;
2
+
3
+ import android.content.Context;
4
+ import android.util.TypedValue;
5
+ import android.view.ViewGroup;
6
+ import android.widget.FrameLayout;
7
+
8
+ import androidx.annotation.NonNull;
9
+ import androidx.recyclerview.widget.LinearLayoutManager;
10
+ import androidx.recyclerview.widget.RecyclerView;
11
+
12
+ /**
13
+ * RecyclerView-backed virtualized list used by PythonNative's Android bridge.
14
+ *
15
+ * <p>Chaquopy can proxy Java interfaces from Python, but it can't subclass Java
16
+ * abstract classes such as RecyclerView.Adapter or RecyclerView.ViewHolder. This
17
+ * helper owns those abstract-class implementations in Java and delegates row
18
+ * content back to Python through the small Delegate interface below.</p>
19
+ *
20
+ * <p>Rows may have per-position heights (section lists interleave headers and
21
+ * items); the delegate reports each row's height in dp. Scroll changes are
22
+ * forwarded to the delegate in dp so the Python side can drive on_scroll,
23
+ * on_end_reached, and viewability callbacks with one code path per platform.</p>
24
+ */
25
+ public class PNVirtualListView extends RecyclerView {
26
+ public interface Delegate {
27
+ int getCount();
28
+
29
+ float getRowHeightDp(int position);
30
+
31
+ void mountRow(int position, FrameLayout container, float widthDp, float heightDp);
32
+
33
+ void onRowPress(int position);
34
+
35
+ void onRowRecycled(FrameLayout container);
36
+
37
+ void onScrolled(float offsetDp, float extentDp, float rangeDp);
38
+ }
39
+
40
+ private final float density;
41
+ private final RowAdapter rowAdapter;
42
+ private Delegate delegate;
43
+
44
+ public PNVirtualListView(@NonNull Context context, @NonNull Delegate delegate) {
45
+ super(context);
46
+ this.delegate = delegate;
47
+ this.density = context.getResources().getDisplayMetrics().density;
48
+ setLayoutManager(new LinearLayoutManager(context));
49
+ rowAdapter = new RowAdapter();
50
+ setAdapter(rowAdapter);
51
+ addOnScrollListener(new OnScrollListener() {
52
+ @Override
53
+ public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
54
+ PNVirtualListView.this.delegate.onScrolled(
55
+ rv.computeVerticalScrollOffset() / PNVirtualListView.this.density,
56
+ rv.computeVerticalScrollExtent() / PNVirtualListView.this.density,
57
+ rv.computeVerticalScrollRange() / PNVirtualListView.this.density
58
+ );
59
+ }
60
+ });
61
+ }
62
+
63
+ public void setDelegate(@NonNull Delegate delegate) {
64
+ this.delegate = delegate;
65
+ rowAdapter.notifyDataSetChanged();
66
+ }
67
+
68
+ public void notifyDataChanged() {
69
+ rowAdapter.notifyDataSetChanged();
70
+ }
71
+
72
+ public void scrollToOffsetDp(float offsetDp, boolean animated) {
73
+ int targetPx = Math.round(offsetDp * density);
74
+ int currentPx = computeVerticalScrollOffset();
75
+ if (animated) {
76
+ smoothScrollBy(0, targetPx - currentPx);
77
+ } else {
78
+ scrollBy(0, targetPx - currentPx);
79
+ }
80
+ }
81
+
82
+ public void scrollToIndex(int position, boolean animated) {
83
+ int clamped = Math.max(0, Math.min(position, Math.max(0, delegate.getCount() - 1)));
84
+ if (animated) {
85
+ smoothScrollToPosition(clamped);
86
+ } else {
87
+ scrollToPosition(clamped);
88
+ }
89
+ }
90
+
91
+ private int rowHeightPx(int position) {
92
+ return Math.max(
93
+ 1,
94
+ Math.round(TypedValue.applyDimension(
95
+ TypedValue.COMPLEX_UNIT_DIP,
96
+ delegate.getRowHeightDp(position),
97
+ getResources().getDisplayMetrics()
98
+ ))
99
+ );
100
+ }
101
+
102
+ private float currentWidthDp(FrameLayout container) {
103
+ int widthPx = container.getWidth();
104
+ if (widthPx <= 0) {
105
+ widthPx = getWidth();
106
+ }
107
+ if (widthPx <= 0) {
108
+ widthPx = getResources().getDisplayMetrics().widthPixels;
109
+ }
110
+ return widthPx / density;
111
+ }
112
+
113
+ private class RowHolder extends RecyclerView.ViewHolder {
114
+ final FrameLayout container;
115
+
116
+ RowHolder(@NonNull FrameLayout container) {
117
+ super(container);
118
+ this.container = container;
119
+ }
120
+ }
121
+
122
+ private class RowAdapter extends RecyclerView.Adapter<RowHolder> {
123
+ @Override
124
+ public int getItemCount() {
125
+ return delegate.getCount();
126
+ }
127
+
128
+ @NonNull
129
+ @Override
130
+ public RowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
131
+ FrameLayout container = new FrameLayout(parent.getContext());
132
+ container.setLayoutParams(
133
+ new RecyclerView.LayoutParams(
134
+ ViewGroup.LayoutParams.MATCH_PARENT,
135
+ ViewGroup.LayoutParams.WRAP_CONTENT
136
+ )
137
+ );
138
+ return new RowHolder(container);
139
+ }
140
+
141
+ @Override
142
+ public void onBindViewHolder(@NonNull RowHolder holder, int position) {
143
+ RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) holder.container.getLayoutParams();
144
+ int heightPx = rowHeightPx(position);
145
+ if (params.height != heightPx) {
146
+ params.height = heightPx;
147
+ holder.container.setLayoutParams(params);
148
+ }
149
+ holder.container.removeAllViews();
150
+ holder.container.setOnClickListener(v -> {
151
+ int pos = holder.getBindingAdapterPosition();
152
+ if (pos != RecyclerView.NO_POSITION) {
153
+ delegate.onRowPress(pos);
154
+ }
155
+ });
156
+ delegate.mountRow(
157
+ position,
158
+ holder.container,
159
+ currentWidthDp(holder.container),
160
+ delegate.getRowHeightDp(position)
161
+ );
162
+ }
163
+
164
+ @Override
165
+ public void onViewRecycled(@NonNull RowHolder holder) {
166
+ holder.container.removeAllViews();
167
+ holder.container.setOnClickListener(null);
168
+ delegate.onRowRecycled(holder.container);
169
+ super.onViewRecycled(holder);
170
+ }
171
+ }
172
+ }