Skip to content

Commit 7a3a695

Browse files
committed
feat(templates,core): adopt Fragment-based Android navigation
1 parent 06ea22d commit 7a3a695

File tree

16 files changed

+355
-178
lines changed

16 files changed

+355
-178
lines changed

docs/api/pythonnative.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ Key flags and helpers (0.2.0):
77
- `pythonnative.utils.IS_ANDROID`: platform flag with robust detection for Chaquopy/Android.
88
- `pythonnative.utils.get_android_context()`: returns the current Android Activity/Context when running on Android.
99
- `pythonnative.utils.set_android_context(ctx)`: set by `pythonnative.Page` on Android; you generally don’t call this directly.
10+
- `pythonnative.utils.get_android_fragment_container()`: returns the current Fragment container `ViewGroup` used for page rendering.
11+
- `pythonnative.utils.set_android_fragment_container(viewGroup)`: set by the host `PageFragment`; you generally don’t call this directly.

docs/concepts/architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ webview.loadUrl("https://example.com")
5050
- Lifecycle rules remain native: Activities/ViewControllers are created by the OS. Python receives and controls them; it does not instantiate Android Activities directly.
5151
- Small, growing surface: the shared Python API favors clarity and consistency, expanding progressively.
5252

53+
## Navigation model overview
54+
55+
- See the Navigation guide for full details and comparisons with other frameworks.
56+
- iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`.
57+
- Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph.
58+
5359
## Related docs
5460

5561
- Guides / Android: guides/android.md

docs/guides/navigation.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,49 @@ PythonNative forwards lifecycle events from the host:
4747
- `on_save_instance_state`
4848
- `on_restore_instance_state`
4949

50-
Android forwards Activity lifecycle via the template `MainActivity` and `PageActivity`. iOS forwards `viewWillAppear`/`viewWillDisappear` via an internal registry.
50+
Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic `PageFragment` per page. iOS forwards `viewWillAppear`/`viewWillDisappear` via an internal registry.
5151

5252
## Notes
5353

54-
- On Android, `push` launches a template `PageActivity` and passes `PY_PAGE_PATH` and optional JSON args.
54+
- On Android, `push` navigates via `NavController` to a `PageFragment` and passes `page_path` and optional JSON `args`.
5555
- On iOS, `push` uses the root `UINavigationController` to push a new `ViewController` and passes page info via KVC.
56+
57+
## Platform specifics
58+
59+
### iOS (UIViewController per page)
60+
- Each PythonNative page is hosted by a Swift `ViewController` instance.
61+
- Pages are pushed and popped on a root `UINavigationController`.
62+
- Lifecycle is forwarded from Swift to the registered Python page instance.
63+
- Root view wiring: `Page.set_root_view` sizes and inserts the Python-native view into the controller’s view.
64+
65+
Why this matches iOS conventions
66+
- iOS apps commonly model screens as `UIViewController`s and use `UINavigationController` for hierarchical navigation.
67+
- The approach integrates cleanly with add-to-app and system behaviors (e.g., state restoration).
68+
69+
### Android (single Activity, Fragment stack)
70+
- Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph.
71+
- Each PythonNative page is represented by a generic `PageFragment` which instantiates the Python page and attaches its root view.
72+
- `push`/`pop` delegate to `NavController` (via a small `Navigator` helper).
73+
- Arguments (`page_path`, `args_json`) live in Fragment arguments and restore across configuration changes and process death.
74+
75+
Why this matches Android conventions
76+
- Modern Android apps favor one Activity with many Fragments, using Jetpack Navigation for back stack, transitions, and deep links.
77+
- It simplifies lifecycle, back handling, and state compared to one-Activity-per-screen.
78+
79+
## Comparison to other frameworks
80+
- React Native
81+
- Android: single `Activity`, screens managed via `Fragment`s (e.g., `react-native-screens`).
82+
- iOS: screens map to `UIViewController`s pushed on `UINavigationController`.
83+
- .NET MAUI / Xamarin.Forms
84+
- Android: single `Activity`, pages via Fragments/Navigation.
85+
- iOS: pages map to `UIViewController`s on a `UINavigationController`.
86+
- NativeScript
87+
- Android: single `Activity`, pages as `Fragment`s.
88+
- iOS: pages as `UIViewController`s on `UINavigationController`.
89+
- Flutter (special case)
90+
- Android: single `Activity` (`FlutterActivity`/`FlutterFragmentActivity`).
91+
- iOS: `FlutterViewController` hosts Flutter’s internal navigator; add-to-app can push multiple `FlutterViewController`s.
92+
93+
Bottom line
94+
- iOS: one host VC class, many instances on a `UINavigationController`.
95+
- Android: one host `Activity`, many `Fragment`s with Jetpack Navigation.

examples/hello-world/app/second_page.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import pythonnative as pn
22

3+
try:
4+
# Optional: iOS styling support (safe if rubicon isn't available)
5+
from rubicon.objc import ObjCClass
6+
7+
UIColor = ObjCClass("UIColor")
8+
except Exception: # pragma: no cover
9+
UIColor = None
10+
311

412
class SecondPage(pn.Page):
513
def __init__(self, native_instance):
@@ -12,6 +20,28 @@ def on_create(self):
1220
args = self.get_args()
1321
message = args.get("message", "Second page!")
1422
stack_view.add_view(pn.Label(message))
23+
# Navigate to Third Page
24+
to_third_btn = pn.Button("Go to Third Page")
25+
# Style button on iOS similar to MainPage
26+
try:
27+
if UIColor is not None:
28+
to_third_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor())
29+
to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0)
30+
except Exception:
31+
pass
32+
33+
def on_next():
34+
# Visual confirmation that tap worked (iOS only)
35+
try:
36+
if UIColor is not None:
37+
to_third_btn.native_instance.setBackgroundColor_(UIColor.systemGreenColor())
38+
to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0)
39+
except Exception:
40+
pass
41+
self.push("app.third_page.ThirdPage", args={"from": "Second"})
42+
43+
to_third_btn.set_on_click(on_next)
44+
stack_view.add_view(to_third_btn)
1545
back_btn = pn.Button("Back")
1646
back_btn.set_on_click(lambda: self.pop())
1747
stack_view.add_view(back_btn)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pythonnative as pn
2+
3+
try:
4+
# Optional: iOS styling support (safe if rubicon isn't available)
5+
from rubicon.objc import ObjCClass
6+
7+
UIColor = ObjCClass("UIColor")
8+
except Exception: # pragma: no cover
9+
UIColor = None
10+
11+
12+
class ThirdPage(pn.Page):
13+
def __init__(self, native_instance):
14+
super().__init__(native_instance)
15+
16+
def on_create(self):
17+
super().on_create()
18+
stack = pn.StackView()
19+
stack.add_view(pn.Label("This is the Third Page"))
20+
back_btn = pn.Button("Back")
21+
# Style button on iOS similar to MainPage
22+
try:
23+
if UIColor is not None:
24+
back_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor())
25+
back_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0)
26+
except Exception:
27+
pass
28+
back_btn.set_on_click(lambda: self.pop())
29+
stack.add_view(back_btn)
30+
self.set_root_view(stack)

src/pythonnative/page.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,20 @@ def __init__(self, native_instance) -> None:
128128
self._args: dict = {}
129129

130130
def set_root_view(self, view) -> None:
131-
self.native_instance.setContentView(view.native_instance)
131+
# In fragment-based navigation, attach child view to the current fragment container.
132+
try:
133+
from .utils import get_android_fragment_container
134+
135+
container = get_android_fragment_container()
136+
# Remove previous children if any, then add the new root
137+
try:
138+
container.removeAllViews()
139+
except Exception:
140+
pass
141+
container.addView(view.native_instance)
142+
except Exception:
143+
# Fallback to setting content view directly on the Activity
144+
self.native_instance.setContentView(view.native_instance)
132145

133146
def on_create(self) -> None:
134147
print("Android on_create() called")
@@ -184,18 +197,26 @@ def _resolve_page_path(self, page: Union[str, Any]) -> str:
184197
raise ValueError("Unsupported page reference; expected dotted string or class/instance")
185198

186199
def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None:
200+
# Delegate to Navigator.push to navigate to PageFragment with arguments
187201
page_path = self._resolve_page_path(page)
188-
package_name = self.native_instance.getPackageName()
189-
intent_cls = jclass("android.content.Intent")
190-
target_activity_cls = jclass(f"{package_name}.PageActivity")
191-
intent = intent_cls(self.native_instance, target_activity_cls)
192-
intent.putExtra("PY_PAGE_PATH", page_path)
193-
if args:
194-
intent.putExtra("PY_PAGE_ARGS_JSON", json.dumps(args))
195-
self.native_instance.startActivity(intent)
202+
try:
203+
Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator")
204+
args_json = json.dumps(args) if args else None
205+
Navigator.push(self.native_instance, page_path, args_json)
206+
except Exception:
207+
# As a last resort, do nothing rather than crash
208+
pass
196209

197210
def pop(self) -> None:
198-
self.native_instance.finish()
211+
# Delegate to Navigator.pop for back-stack pop
212+
try:
213+
Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator")
214+
Navigator.pop(self.native_instance)
215+
except Exception:
216+
try:
217+
self.native_instance.finish()
218+
except Exception:
219+
pass
199220

200221
else:
201222
# ========================================

src/pythonnative/templates/android_template/app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ dependencies {
5353
implementation 'androidx.appcompat:appcompat:1.4.1'
5454
implementation 'com.google.android.material:material:1.5.0'
5555
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
56+
// AndroidX Navigation for Fragment-based navigation
57+
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
58+
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
5659
testImplementation 'junit:junit:4.13.2'
5760
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
5861
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@
2121
<category android:name="android.intent.category.LAUNCHER" />
2222
</intent-filter>
2323
</activity>
24-
<activity
25-
android:name=".PageActivity"
26-
android:exported="false" />
2724
</application>
2825

2926
</manifest>

src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt

Lines changed: 7 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ import android.os.Bundle
55
import android.util.Log
66
import android.widget.TextView
77
import com.chaquo.python.Python
8-
import com.chaquo.python.PyObject
98
import com.chaquo.python.android.AndroidPlatform
109

1110
class MainActivity : AppCompatActivity() {
1211
private val TAG = javaClass.simpleName
13-
private var page: PyObject? = null
1412

1513
override fun onCreate(savedInstanceState: Bundle?) {
1614
super.onCreate(savedInstanceState)
@@ -21,66 +19,17 @@ class MainActivity : AppCompatActivity() {
2119
Python.start(AndroidPlatform(this))
2220
}
2321
try {
22+
// Set content view to the NavHost layout; the initial page loads via nav_graph startDestination
23+
setContentView(R.layout.activity_main)
24+
// Optionally, bootstrap Python so first fragment can create the initial page onCreate
2425
val py = Python.getInstance()
25-
// Instantiate MainPage directly and call on_create
26-
val module = py.getModule("app.main_page")
27-
val pageClass = module.get("MainPage")
28-
page = pageClass?.call(this)
29-
page?.callAttr("on_create")
26+
// Touch module to ensure bundled Python code is available; actual instantiation happens in PageFragment
27+
py.getModule("app.main_page")
3028
} catch (e: Exception) {
31-
Log.e("PythonNative", "Python bootstrap failed", e)
32-
// Fallback: show a simple native label if Python bootstrap fails
29+
Log.e("PythonNative", "Bootstrap failed", e)
3330
val tv = TextView(this)
3431
tv.text = "Hello from PythonNative (Android template)"
3532
setContentView(tv)
3633
}
3734
}
38-
39-
override fun onStart() {
40-
super.onStart()
41-
Log.d(TAG, "onStart() called")
42-
try { page?.callAttr("on_start") } catch (e: Exception) { Log.w(TAG, "on_start failed", e) }
43-
}
44-
45-
override fun onResume() {
46-
super.onResume()
47-
Log.d(TAG, "onResume() called")
48-
try { page?.callAttr("on_resume") } catch (e: Exception) { Log.w(TAG, "on_resume failed", e) }
49-
}
50-
51-
override fun onPause() {
52-
super.onPause()
53-
Log.d(TAG, "onPause() called")
54-
try { page?.callAttr("on_pause") } catch (e: Exception) { Log.w(TAG, "on_pause failed", e) }
55-
}
56-
57-
override fun onStop() {
58-
super.onStop()
59-
Log.d(TAG, "onStop() called")
60-
try { page?.callAttr("on_stop") } catch (e: Exception) { Log.w(TAG, "on_stop failed", e) }
61-
}
62-
63-
override fun onDestroy() {
64-
super.onDestroy()
65-
Log.d(TAG, "onDestroy() called")
66-
try { page?.callAttr("on_destroy") } catch (e: Exception) { Log.w(TAG, "on_destroy failed", e) }
67-
}
68-
69-
override fun onRestart() {
70-
super.onRestart()
71-
Log.d(TAG, "onRestart() called")
72-
try { page?.callAttr("on_restart") } catch (e: Exception) { Log.w(TAG, "on_restart failed", e) }
73-
}
74-
75-
override fun onSaveInstanceState(outState: Bundle) {
76-
super.onSaveInstanceState(outState)
77-
Log.d(TAG, "onSaveInstanceState() called")
78-
try { page?.callAttr("on_save_instance_state") } catch (e: Exception) { Log.w(TAG, "on_save_instance_state failed", e) }
79-
}
80-
81-
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
82-
super.onRestoreInstanceState(savedInstanceState)
83-
Log.d(TAG, "onRestoreInstanceState() called")
84-
try { page?.callAttr("on_restore_instance_state") } catch (e: Exception) { Log.w(TAG, "on_restore_instance_state failed", e) }
85-
}
86-
}
35+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.pythonnative.android_template
2+
3+
import android.os.Bundle
4+
import androidx.core.os.bundleOf
5+
import androidx.fragment.app.FragmentActivity
6+
import androidx.navigation.fragment.NavHostFragment
7+
8+
object Navigator {
9+
@JvmStatic
10+
fun push(activity: FragmentActivity, pagePath: String, argsJson: String?) {
11+
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
12+
val navController = navHost.navController
13+
val args = Bundle()
14+
args.putString("page_path", pagePath)
15+
if (argsJson != null) {
16+
args.putString("args_json", argsJson)
17+
}
18+
navController.navigate(R.id.pageFragment, args)
19+
}
20+
21+
@JvmStatic
22+
fun pop(activity: FragmentActivity) {
23+
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
24+
navHost.navController.popBackStack()
25+
}
26+
}

0 commit comments

Comments
 (0)