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.
- pythonnative/__init__.py +16 -2
- pythonnative/appearance.py +124 -0
- pythonnative/components.py +708 -38
- pythonnative/hooks.py +41 -0
- pythonnative/images.py +196 -0
- pythonnative/mutations.py +3 -12
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +573 -45
- pythonnative/native_views/desktop.py +78 -19
- pythonnative/native_views/ios.py +739 -51
- pythonnative/preview.py +31 -0
- pythonnative/project/config.py +1 -9
- pythonnative/screen.py +85 -0
- pythonnative/style.py +65 -6
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/METADATA +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/RECORD +25 -19
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {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)
|
pythonnative/project/config.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
505
|
-
"""
|
|
514
|
+
_FOLLOW_SYSTEM_THEME = object()
|
|
515
|
+
"""Sentinel default for `ThemeContext`: resolve from the color scheme."""
|
|
506
516
|
|
|
507
|
-
|
|
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
|
-
|
|
510
|
-
via [`
|
|
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
|
]
|
|
@@ -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
|
+
}
|