From ff2f619c8293c296732be6eedd6599f5dd679745 Mon Sep 17 00:00:00 2001
From: Adel Ali <3adeling@gmail.com>
Date: Mon, 9 Mar 2026 05:20:07 +0300
Subject: [PATCH 1/4] add RTL support
---
resources/xcode/NativePHP/AppDelegate.swift | 49 ++-
resources/xcode/NativePHP/Info.plist | 5 +
resources/xcode/NativePHP/NativePHPApp.swift | 50 ++-
.../NativePHP/NativeUI/NativeBottomNav.swift | 12 +-
.../NativePHP/NativeUI/NativeSideNav.swift | 416 +++++++++---------
.../NativePHP/NativeUI/NativeTopBar.swift | 26 +-
6 files changed, 294 insertions(+), 264 deletions(-)
diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift
index 0295973..c96bc78 100644
--- a/resources/xcode/NativePHP/AppDelegate.swift
+++ b/resources/xcode/NativePHP/AppDelegate.swift
@@ -33,9 +33,26 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Called when the app is launched
func application(
- _ application: UIApplication,
- didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
+
+ let locale = Locale.current.language.languageCode?.identifier ?? "en"
+ let isRTL = Locale.characterDirection(forLanguage: locale) == .rightToLeft
+
+ let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
+
+ // RTL: Force right-to-left layout direction for all UIKit views app-wide.
+ // Must be called before any window or view is created so appearance proxies
+ // take effect on every UIKit component (UITabBar, UINavigationBar, etc.).
+ UIView.appearance().semanticContentAttribute = direction
+ UINavigationBar.appearance().semanticContentAttribute = direction
+ UITabBar.appearance().semanticContentAttribute = direction
+ UITableView.appearance().semanticContentAttribute = direction
+ UICollectionView.appearance().semanticContentAttribute = direction
+ UITextField.appearance().textAlignment = .natural
+ UILabel.appearance().textAlignment = .natural
+
// Check if the app was launched from a URL (custom scheme)
if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL {
DebugLogger.shared.log("📱 AppDelegate: Cold start with custom scheme URL: \(url)")
@@ -45,9 +62,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Check if the app was launched from a Universal Link
if let userActivityDictionary = launchOptions?[UIApplication.LaunchOptionsKey.userActivityDictionary] as? [String: Any],
- let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity,
- userActivity.activityType == NSUserActivityTypeBrowsingWeb,
- let url = userActivity.webpageURL {
+ let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity,
+ userActivity.activityType == NSUserActivityTypeBrowsingWeb,
+ let url = userActivity.webpageURL {
DebugLogger.shared.log("📱 AppDelegate: Cold start with Universal Link: \(url)")
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
@@ -58,13 +75,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Called for Universal Links
func application(
- _ application: UIApplication,
- continue userActivity: NSUserActivity,
- restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
+ _ application: UIApplication,
+ continue userActivity: NSUserActivity,
+ restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Check if this is a Universal Link
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
- let url = userActivity.webpageURL {
+ let url = userActivity.webpageURL {
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
return true
@@ -76,8 +93,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// MARK: - Push Notification Token Handling (forwards to plugins via NotificationCenter)
func application(
- _ application: UIApplication,
- didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+ _ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
NotificationCenter.default.post(
name: .didRegisterForRemoteNotifications,
@@ -87,8 +104,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
func application(
- _ application: UIApplication,
- didFailToRegisterForRemoteNotificationsWithError error: Error
+ _ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error
) {
NotificationCenter.default.post(
name: .didFailToRegisterForRemoteNotifications,
@@ -99,9 +116,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Handle deeplinks
func application(
- _ app: UIApplication,
- open url: URL,
- options: [UIApplication.OpenURLOptionsKey: Any] = [:]
+ _ app: UIApplication,
+ open url: URL,
+ options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
diff --git a/resources/xcode/NativePHP/Info.plist b/resources/xcode/NativePHP/Info.plist
index 7d6768e..6ac6450 100644
--- a/resources/xcode/NativePHP/Info.plist
+++ b/resources/xcode/NativePHP/Info.plist
@@ -15,6 +15,11 @@
+ CFBundleLocalizations
+
+ ar
+ en
+
NSAppTransportSecurity
NSAllowsArbitraryLoadsInWebContent
diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift
index f7bc114..8430a0b 100644
--- a/resources/xcode/NativePHP/NativePHPApp.swift
+++ b/resources/xcode/NativePHP/NativePHPApp.swift
@@ -81,22 +81,30 @@ struct NativePHPApp: App {
// It renders underneath the splash until WebView finishes loading
if appState.isReadyToLoad {
ContentView()
+ .environment(\.layoutDirection, .rightToLeft)
+ .environment(\.locale, Locale(identifier: "ar"))
}
// Splash overlays until WebView finishes loading (Phase 3)
if !appState.isInitialized {
SplashView()
- .transition(.opacity)
- .onAppear {
- // Phase 1: Start deferred initialization on a background thread
- // This runs AFTER the splash view is visible, avoiding watchdog timeout
- DispatchQueue.global(qos: .userInitiated).async {
- performDeferredInitialization()
- }
+ .transition(.opacity)
+ .onAppear {
+ // Phase 1: Start deferred initialization on a background thread
+ // This runs AFTER the splash view is visible, avoiding watchdog timeout
+ DispatchQueue.global(qos: .userInitiated).async {
+ performDeferredInitialization()
}
+ }
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isInitialized)
+ // RTL: Apply right-to-left layout direction to the entire SwiftUI view hierarchy.
+ // This covers NativeSideNav, SideNavItemView, SideNavGroupView, and all
+ // other SwiftUI-based NativeUI components. UIKit components (UITabBar,
+ // UINavigationBar) are handled separately via UIView.appearance() in AppDelegate.
+ .environment(\.layoutDirection, .rightToLeft)
+ .environment(\.locale, Locale(identifier: "ar"))
.onOpenURL { url in
// Only handle if not already handled by AppDelegate during cold start
if !DeepLinkRouter.shared.hasPendingURL() {
@@ -142,7 +150,7 @@ struct NativePHPApp: App {
let envPath = URL(fileURLWithPath: appPath).appendingPathComponent(".env")
guard FileManager.default.fileExists(atPath: envPath.path),
- let envContent = try? String(contentsOf: envPath, encoding: .utf8) else {
+ let envContent = try? String(contentsOf: envPath, encoding: .utf8) else {
DebugLogger.shared.log("⚙️ No .env file found, using default start URL")
return "/"
}
@@ -150,11 +158,11 @@ struct NativePHPApp: App {
// Use regex to find NATIVEPHP_START_URL value
let pattern = #"NATIVEPHP_START_URL\s*=\s*([^\r\n]+)"#
if let regex = try? NSRegularExpression(pattern: pattern),
- let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)),
- let valueRange = Range(match.range(at: 1), in: envContent) {
+ let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)),
+ let valueRange = Range(match.range(at: 1), in: envContent) {
var value = String(envContent[valueRange])
- .trimmingCharacters(in: .whitespaces)
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
+ .trimmingCharacters(in: .whitespaces)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
if !value.isEmpty {
// Ensure path starts with /
@@ -174,9 +182,9 @@ struct NativePHPApp: App {
let caPath = Bundle.main.path(forResource: "cacert", ofType: "pem") ?? "Path not found"
let phpIni = """
- curl.cainfo="\(caPath)"
- openssl.cafile="\(caPath)"
- """
+ curl.cainfo="\(caPath)"
+ openssl.cafile="\(caPath)"
+ """
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let path = supportDir.appendingPathComponent("php.ini")
@@ -196,7 +204,7 @@ struct NativePHPApp: App {
let fileManager = FileManager.default
let databaseFileURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
- .appendingPathComponent("database/database.sqlite")
+ .appendingPathComponent("database/database.sqlite")
if !fileManager.fileExists(atPath: databaseFileURL.path) {
// Create an empty SQLite file
@@ -286,12 +294,12 @@ struct NativePHPApp: App {
for (header, value) in request.headers {
let formattedKey = "HTTP_" + header
- .replacingOccurrences(of: "-", with: "_")
- .uppercased()
+ .replacingOccurrences(of: "-", with: "_")
+ .uppercased()
// Convert Swift strings to C strings
guard let cKey = formattedKey.cString(using: .utf8),
- let cValue = value.cString(using: .utf8) else {
+ let cValue = value.cString(using: .utf8) else {
print("Failed to convert \(header) or its value to C string.")
continue
}
@@ -375,8 +383,8 @@ struct NativePHPApp: App {
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
- let data = result as? Data,
- let existingKey = String(data: data, encoding: .utf8) {
+ let data = result as? Data,
+ let existingKey = String(data: data, encoding: .utf8) {
DebugLogger.shared.log("🔑 Retrieved existing APP_KEY from Keychain")
return existingKey
}
diff --git a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
index c1006f7..bbfd5f4 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
@@ -27,6 +27,10 @@ struct NativeUITabBar: UIViewRepresentable {
tabBar.scrollEdgeAppearance = appearance
}
+ // RTL: Force right-to-left layout so tab items are ordered right-to-left,
+ // matching the natural reading direction for Arabic apps.
+ tabBar.semanticContentAttribute = .forceRightToLeft
+
// Apply custom active color (tint color for selected items)
if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) {
tabBar.tintColor = color
@@ -241,7 +245,7 @@ struct NativeUITabBar: UIViewRepresentable {
}
guard let index = tabBar.items?.firstIndex(of: item),
- index < items.count else { return }
+ index < items.count else { return }
let selectedItem = items[index]
@@ -270,7 +274,7 @@ struct NativeBottomNavigation: View {
var body: some View {
if let bottomNavData = uiState.bottomNavData,
- let items = bottomNavData.children, !items.isEmpty {
+ let items = bottomNavData.children, !items.isEmpty {
if #available(iOS 26.0, *) {
// iOS 26+: Tab bar with Liquid Glass effect
@@ -346,8 +350,8 @@ struct NativeBottomNavigation: View {
/// Load URL for a specific tab by finding the item and passing its raw URL
private func loadTabURL(tabId: String) {
guard let bottomNavData = uiState.bottomNavData,
- let items = bottomNavData.children,
- let item = items.first(where: { $0.data.id == tabId }) else {
+ let items = bottomNavData.children,
+ let item = items.first(where: { $0.data.id == tabId }) else {
print("❌ No item found for tab: \(tabId)")
return
}
diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
index 7538acc..9bda7b1 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
@@ -1,18 +1,43 @@
import SwiftUI
-/// Dynamic Side Navigation using slide-in drawer with swipe gesture
+/// Bidirectional Side Navigation — supports both LTR and RTL layouts.
+///
+/// Reads `layoutDirection` from the SwiftUI environment and adapts automatically:
+/// - LTR: drawer slides in from the LEFT edge
+/// - RTL: drawer slides in from the RIGHT edge
+///
+/// All offset and gesture math is normalised using a `dir` multiplier:
+/// dir = -1 (LTR) → negative x hides drawer off-screen left
+/// dir = +1 (RTL) → positive x hides drawer off-screen right
+///
+/// slideOffset = 0 → drawer fully visible at its edge
+/// slideOffset = drawerWidth * dir → drawer fully hidden off-screen
struct NativeSideNavigation: View {
@ObservedObject var uiState = NativeUIState.shared
+ private var isRTL: Bool { UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft }
@State private var expandedGroups: Set = []
@State private var dragOffset: CGFloat = 0
let content: Content
let onNavigate: (String) -> Void
- // Drawer dimensions
- private let drawerWidthRatio: CGFloat = 0.85 // 85% of screen width
+ private let drawerWidthRatio: CGFloat = 0.85
private let edgeSwipeThreshold: CGFloat = 30
+ /// +1 for RTL (drawer on right), -1 for LTR (drawer on left).
+ /// Multiplying any offset or velocity by this normalises direction math.
+ private var dir: CGFloat { isRTL ? 1 : -1 }
+
+ /// The physical edge the drawer appears on.
+ private var drawerEdge: Edge { isRTL ? .trailing : .leading }
+
+ /// Safe area inset on the drawer's side.
+ private func safeInset(_ geometry: GeometryProxy) -> CGFloat {
+ isRTL
+ ? geometry.safeAreaInsets.trailing
+ : geometry.safeAreaInsets.leading
+ }
+
init(onNavigate: @escaping (String) -> Void, @ViewBuilder content: () -> Content) {
self.onNavigate = onNavigate
self.content = content()
@@ -20,202 +45,185 @@ struct NativeSideNavigation: View {
var body: some View {
GeometryReader { geometry in
- // Use smaller drawer width in landscape mode
let isLandscape = geometry.size.width > geometry.size.height
- let drawerWidthMultiplier = isLandscape ? 0.4 : drawerWidthRatio
- let drawerWidth = geometry.size.width * drawerWidthMultiplier
-
- // Computed drawer X offset based on state and drag
- // Add extra offset to account for safe area and ensure complete hiding
+ let drawerWidth = geometry.size.width * (isLandscape ? 0.4 : drawerWidthRatio)
+ let safeArea = safeInset(geometry)
+
+ // Hidden offset: moves drawer fully off-screen on its side.
+ // dir * (drawerWidth + safeArea + 10)
+ // LTR → negative x → off-screen LEFT
+ // RTL → positive x → off-screen RIGHT
+ let hiddenOffset = dir * (drawerWidth + safeArea + 10)
+
+ // Current drawer x position:
+ // open: 0 (drawer at edge, fully visible)
+ // closed: hiddenOffset (drawer off-screen)
let drawerXOffset: CGFloat = {
- let safeAreaOffset = geometry.safeAreaInsets.leading
- let baseOffset = uiState.shouldPresentSidebar ? 0 : -(drawerWidth + safeAreaOffset + 10)
- return baseOffset + dragOffset
+ let base: CGFloat = uiState.shouldPresentSidebar ? 0 : hiddenOffset
+ return base + dragOffset
}()
- // Overlay opacity based on drawer position
+ // Overlay opacity: 0 when hidden, 0.5 when fully open
let overlayOpacity: Double = {
let progress = 1 - abs(drawerXOffset) / drawerWidth
return Double(max(0, min(0.5, progress * 0.5)))
}()
- // Edge swipe gesture to open drawer
+ // Swipe inward from the drawer's edge to open.
+ // Inward direction = opposite of dir:
+ // LTR: swipe right (+) from left edge
+ // RTL: swipe left (-) from right edge
let edgeSwipeGesture = DragGesture(minimumDistance: 10)
- .onChanged { value in
- // Only open if swiping right from the very left edge (0 to threshold)
- if value.translation.width > 0 && value.startLocation.x < edgeSwipeThreshold {
- let safeAreaOffset = geometry.safeAreaInsets.leading
- let maxOffset = drawerWidth + safeAreaOffset + 10
- // Clamp to not go beyond fully open (0), but don't stop the gesture
- dragOffset = min(value.translation.width, maxOffset)
- }
- }
- .onEnded { value in
- // Open if swiped far enough or with enough velocity
- let threshold = drawerWidth * 0.3
- let velocity = value.predictedEndTranslation.width - value.translation.width
-
- if value.translation.width > threshold || velocity > 300 {
- withAnimation(.easeOut(duration: 0.25)) {
- uiState.openSidebar()
- dragOffset = 0
- }
- } else {
- withAnimation(.easeOut(duration: 0.25)) {
- dragOffset = 0
- }
- }
- }
+ .onChanged { value in
+ // Must start from the correct physical edge
+ let atEdge = isRTL
+ ? value.startLocation.x > geometry.size.width - edgeSwipeThreshold
+ : value.startLocation.x < edgeSwipeThreshold
- // Drawer drag gesture to close
- let drawerDragGesture = DragGesture()
- .onChanged { value in
- // Only allow dragging left to close
- if value.translation.width < 0 {
- let safeAreaOffset = geometry.safeAreaInsets.leading
- let maxDrag = drawerWidth + safeAreaOffset + 10
- // Clamp to not go beyond fully closed, but don't stop the gesture
- dragOffset = max(value.translation.width, -maxDrag)
- }
+ guard atEdge else { return }
+
+ // Normalised translation: negative = inward (opening)
+ let norm = dir * value.translation.width
+ guard norm < 0 else { return }
+
+ // dragOffset mirrors the normalised translation back to screen coords
+ // and clamps so drawer can't go past fully open
+ dragOffset = max(norm, -abs(hiddenOffset)) * dir
+ }
+ .onEnded { value in
+ let normTranslation = dir * value.translation.width
+ let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width)
+
+ if abs(normTranslation) > drawerWidth * 0.3 || normVelocity < -300 {
+ open()
+ } else {
+ withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 }
}
- .onEnded { value in
- // Close if dragged far enough or with enough velocity
- let threshold = drawerWidth * 0.3
- let velocity = value.predictedEndTranslation.width - value.translation.width
-
- if abs(value.translation.width) > threshold || velocity < -300 {
- withAnimation(.easeOut(duration: 0.25)) {
- uiState.closeSidebar()
- dragOffset = 0
- }
- } else {
- withAnimation(.easeOut(duration: 0.25)) {
- dragOffset = 0
- }
- }
+ }
+
+ // Swipe outward on drawer or overlay to close.
+ // Outward direction = same as dir:
+ // LTR: swipe left (-) → close
+ // RTL: swipe right (+) → close
+ let closeDragGesture = DragGesture()
+ .onChanged { value in
+ // Normalised translation: positive = outward (closing)
+ let norm = dir * value.translation.width
+ guard norm > 0 else { return }
+ dragOffset = min(norm, abs(hiddenOffset)) * dir
+ }
+ .onEnded { value in
+ let normTranslation = dir * value.translation.width
+ let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width)
+
+ if normTranslation > drawerWidth * 0.3 || normVelocity > 300 {
+ close()
+ } else {
+ withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 }
}
+ }
- ZStack(alignment: .leading) {
- // Main content
+ ZStack(alignment: isRTL ? .trailing : .leading) {
+ // ── Main content ──────────────────────────────────────────────
content
- .zIndex(0)
- .disabled(uiState.shouldPresentSidebar)
+ .zIndex(0)
+ .disabled(uiState.shouldPresentSidebar)
- // Dimmed overlay when drawer is open or being dragged
+ // ── Dim overlay ───────────────────────────────────────────────
if uiState.shouldPresentSidebar || dragOffset != 0 {
Color.black
- .opacity(overlayOpacity)
- .ignoresSafeArea()
- .zIndex(1)
- .onTapGesture {
- withAnimation(.easeInOut(duration: 0.3)) {
- uiState.closeSidebar()
- dragOffset = 0
- }
- }
- .gesture(
- DragGesture()
- .onChanged { value in
- // Allow closing by swiping left on overlay
- if value.translation.width < 0 {
- let safeAreaOffset = geometry.safeAreaInsets.leading
- let maxDrag = drawerWidth + safeAreaOffset + 10
- // Clamp to not go beyond fully closed, but don't stop the gesture
- dragOffset = max(value.translation.width, -maxDrag)
- }
- }
- .onEnded { value in
- let threshold = drawerWidth * 0.3
- let velocity = value.predictedEndTranslation.width - value.translation.width
-
- if abs(value.translation.width) > threshold || velocity < -300 {
- withAnimation(.easeOut(duration: 0.25)) {
- uiState.closeSidebar()
- dragOffset = 0
- }
- } else {
- withAnimation(.easeOut(duration: 0.25)) {
- dragOffset = 0
- }
- }
- }
- )
- .transition(.opacity)
+ .opacity(overlayOpacity)
+ .ignoresSafeArea()
+ .zIndex(1)
+ .onTapGesture { close() }
+ .gesture(closeDragGesture)
+ .transition(.opacity)
}
- // Side drawer
+ // ── Side drawer ───────────────────────────────────────────────
if uiState.hasSideNav() {
drawerContent
- .frame(width: drawerWidth)
- .background(Color(.systemBackground))
- .offset(x: drawerXOffset)
- .zIndex(2)
- .gesture(drawerDragGesture)
- .onAppear {
- print("📱 NativeSideNavigation: Side nav available")
- // Initialize expanded groups from data
- if let children = uiState.sideNavData?.children {
- for child in children where child.type == "side_nav_group" {
- if case .group(let group) = child.data,
- group.expanded == true {
- expandedGroups.insert(group.heading)
- }
+ .frame(width: drawerWidth)
+ .background(Color(.systemBackground))
+ .offset(x: drawerXOffset)
+ .zIndex(2)
+ .gesture(closeDragGesture)
+ .onAppear {
+ let side = isRTL ? "RIGHT (RTL)" : "LEFT (LTR)"
+ print("📱 NativeSideNavigation: drawer on \(side) edge")
+ if let children = uiState.sideNavData?.children {
+ for child in children where child.type == "side_nav_group" {
+ if case .group(let group) = child.data,
+ group.expanded == true {
+ expandedGroups.insert(group.heading)
}
}
}
+ }
}
- // Invisible edge detector for swipe-to-open
+ // ── Invisible edge detector for swipe-to-open ────────────────
if uiState.hasSideNav() && !uiState.shouldPresentSidebar {
Color.clear
- .frame(width: edgeSwipeThreshold)
- .contentShape(Rectangle())
- .gesture(edgeSwipeGesture)
- .ignoresSafeArea(edges: .leading)
- .zIndex(3)
+ .frame(width: edgeSwipeThreshold)
+ .contentShape(Rectangle())
+ .gesture(edgeSwipeGesture)
+ .ignoresSafeArea(edges: isRTL ? .trailing : .leading)
+ .zIndex(3)
}
}
.onChange(of: uiState.shouldPresentSidebar) { _, newValue in
if newValue {
- withAnimation(.easeInOut(duration: 0.3)) {
- dragOffset = 0
- }
+ withAnimation(.easeInOut(duration: 0.3)) { dragOffset = 0 }
}
}
}
}
- // Drawer content
+ // MARK: - Actions
+
+ private func open() {
+ withAnimation(.easeOut(duration: 0.25)) {
+ uiState.openSidebar()
+ dragOffset = 0
+ }
+ }
+
+ private func close() {
+ withAnimation(.easeOut(duration: 0.25)) {
+ uiState.closeSidebar()
+ dragOffset = 0
+ }
+ }
+
+ // MARK: - Drawer Content
+
@ViewBuilder
private var drawerContent: some View {
if let sideNavData = uiState.sideNavData,
- let children = sideNavData.children {
- // Separate pinned headers from scrollable content
+ let children = sideNavData.children {
let pinnedHeaders = children.filter { child in
if child.type == "side_nav_header",
- case .header(let header) = child.data {
+ case .header(let header) = child.data {
return header.pinned == true
}
return false
}
let scrollableChildren = children.filter { child in
if child.type == "side_nav_header",
- case .header(let header) = child.data {
+ case .header(let header) = child.data {
return header.pinned != true
}
return true
}
VStack(spacing: 0) {
- // Pinned headers at the top (non-scrollable)
- ForEach(Array(pinnedHeaders.enumerated()), id: \.offset) { index, child in
+ ForEach(Array(pinnedHeaders.enumerated()), id: \.offset) { _, child in
sideNavChild(child: child)
}
-
- // Scrollable content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(scrollableChildren.enumerated()), id: \.offset) { index, child in
+ ForEach(Array(scrollableChildren.enumerated()), id: \.offset) { _, child in
sideNavChild(child: child)
}
}
@@ -232,7 +240,6 @@ struct NativeSideNavigation: View {
if case .header(let header) = child.data {
SideNavHeaderView(header: header)
}
-
case "side_nav_item":
if case .item(let item) = child.data {
SideNavItemView(
@@ -240,13 +247,10 @@ struct NativeSideNavigation: View {
labelVisibility: uiState.sideNavData?.labelVisibility,
onNavigate: { url in
onNavigate(url)
- withAnimation(.easeInOut(duration: 0.3)) {
- uiState.closeSidebar()
- }
+ withAnimation(.easeInOut(duration: 0.3)) { uiState.closeSidebar() }
}
)
}
-
case "side_nav_group":
if case .group(let group) = child.data {
SideNavGroupView(
@@ -264,50 +268,39 @@ struct NativeSideNavigation: View {
labelVisibility: uiState.sideNavData?.labelVisibility,
onNavigate: { url in
onNavigate(url)
- withAnimation(.easeInOut(duration: 0.3)) {
- uiState.closeSidebar()
- }
+ withAnimation(.easeInOut(duration: 0.3)) { uiState.closeSidebar() }
}
)
}
-
case "horizontal_divider":
- Divider()
- .padding(.vertical, 8)
-
+ Divider().padding(.vertical, 8)
default:
EmptyView()
}
}
}
-/// Side nav header view
+// MARK: - SideNavHeaderView
+
struct SideNavHeaderView: View {
let header: SideNavHeader
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 16) {
- // Icon
if let iconName = header.icon {
Image(systemName: getIconForName(iconName))
- .font(.system(size: 40))
- .foregroundColor(.accentColor)
+ .font(.system(size: 40))
+ .foregroundColor(.accentColor)
}
-
- // Title and subtitle
VStack(alignment: .leading, spacing: 4) {
if let title = header.title {
- Text(title)
- .font(.headline)
+ Text(title).font(.headline)
}
if let subtitle = header.subtitle {
- Text(subtitle)
- .font(.subheadline)
- .foregroundColor(.secondary)
+ Text(subtitle).font(.subheadline).foregroundColor(.secondary)
}
}
-
Spacer()
}
.padding(16)
@@ -320,22 +313,20 @@ struct SideNavHeaderView: View {
private func parseBackgroundColor(_ colorString: String?) -> Color {
guard let colorString = colorString else { return Color(.systemGray6) }
- // Simple hex color parsing
let hex = colorString.replacingOccurrences(of: "#", with: "")
guard hex.count == 6 || hex.count == 8 else { return Color(.systemGray6) }
-
var rgb: UInt64 = 0
Scanner(string: hex).scanHexInt64(&rgb)
-
- let r = Double((rgb >> 16) & 0xFF) / 255.0
- let g = Double((rgb >> 8) & 0xFF) / 255.0
- let b = Double(rgb & 0xFF) / 255.0
-
- return Color(red: r, green: g, blue: b)
+ return Color(
+ red: Double((rgb >> 16) & 0xFF) / 255.0,
+ green: Double((rgb >> 8) & 0xFF) / 255.0,
+ blue: Double(rgb & 0xFF) / 255.0
+ )
}
}
-/// Single side nav item
+// MARK: - SideNavItemView
+
struct SideNavItemView: View {
let item: SideNavItem
let labelVisibility: String?
@@ -349,27 +340,26 @@ struct SideNavItemView: View {
}) {
HStack(spacing: 16) {
Image(systemName: getIconForName(item.icon))
- .font(.system(size: 20))
- .foregroundColor(item.active == true ? .accentColor : .primary)
- .frame(width: 24)
+ .font(.system(size: 20))
+ .foregroundColor(item.active == true ? .accentColor : .primary)
+ .frame(width: 24)
if shouldShowLabel() {
Text(item.label)
- .foregroundColor(item.active == true ? .accentColor : .primary)
+ .foregroundColor(item.active == true ? .accentColor : .primary)
}
Spacer()
- // Badge
if let badge = item.badge {
Text(badge)
- .font(.caption2)
- .fontWeight(.semibold)
- .foregroundColor(.white)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(parseBadgeColor(item.badgeColor))
- .cornerRadius(12)
+ .font(.caption2)
+ .fontWeight(.semibold)
+ .foregroundColor(.white)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(parseBadgeColor(item.badgeColor))
+ .cornerRadius(12)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -385,18 +375,15 @@ struct SideNavItemView: View {
private func shouldShowLabel() -> Bool {
switch labelVisibility {
case "unlabeled": return false
- case "selected": return item.active == true
- default: return true
+ case "selected": return item.active == true
+ default: return true
}
}
private func handleNavigation() {
- // Check if should open in browser
if item.openInBrowser == true || isExternalUrl(item.url) {
print("🌐 Opening external URL: \(item.url)")
- if let url = URL(string: item.url) {
- UIApplication.shared.open(url)
- }
+ if let url = URL(string: item.url) { UIApplication.shared.open(url) }
} else {
print("📱 Opening internal URL: \(item.url)")
onNavigate(item.url)
@@ -404,27 +391,28 @@ struct SideNavItemView: View {
}
private func isExternalUrl(_ url: String) -> Bool {
- return (url.hasPrefix("http://") || url.hasPrefix("https://"))
- && !url.contains("127.0.0.1")
- && !url.contains("localhost")
+ (url.hasPrefix("http://") || url.hasPrefix("https://"))
+ && !url.contains("127.0.0.1")
+ && !url.contains("localhost")
}
private func parseBadgeColor(_ colorString: String?) -> Color {
switch colorString?.lowercased() {
- case "lime": return Color(red: 0.52, green: 0.8, blue: 0.09)
- case "green": return Color(red: 0.13, green: 0.77, blue: 0.37)
- case "blue": return Color(red: 0.23, green: 0.51, blue: 0.96)
- case "red": return Color(red: 0.94, green: 0.27, blue: 0.27)
+ case "lime": return Color(red: 0.52, green: 0.80, blue: 0.09)
+ case "green": return Color(red: 0.13, green: 0.77, blue: 0.37)
+ case "blue": return Color(red: 0.23, green: 0.51, blue: 0.96)
+ case "red": return Color(red: 0.94, green: 0.27, blue: 0.27)
case "yellow": return Color(red: 0.92, green: 0.70, blue: 0.03)
case "purple": return Color(red: 0.66, green: 0.33, blue: 0.97)
- case "pink": return Color(red: 0.93, green: 0.28, blue: 0.60)
+ case "pink": return Color(red: 0.93, green: 0.28, blue: 0.60)
case "orange": return Color(red: 0.98, green: 0.45, blue: 0.09)
- default: return Color(red: 0.39, green: 0.40, blue: 0.95) // Indigo
+ default: return Color(red: 0.39, green: 0.40, blue: 0.95)
}
}
}
-/// Expandable group of items
+// MARK: - SideNavGroupView
+
struct SideNavGroupView: View {
let group: SideNavGroup
let isExpanded: Bool
@@ -434,24 +422,21 @@ struct SideNavGroupView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
- // Group header
Button(action: onToggle) {
HStack(spacing: 16) {
if let iconName = group.icon {
Image(systemName: getIconForName(iconName))
- .font(.system(size: 20))
- .frame(width: 24)
+ .font(.system(size: 20))
+ .frame(width: 24)
}
-
- Text(group.heading)
- .fontWeight(.medium)
-
+ Text(group.heading).fontWeight(.medium)
Spacer()
-
- Image(systemName: "chevron.right")
- .font(.system(size: 12, weight: .semibold))
- .foregroundColor(.secondary)
- .rotationEffect(.degrees(isExpanded ? 90 : 0))
+ // chevron.forward auto-mirrors with layoutDirection:
+ // LTR → points right, RTL → points left
+ Image(systemName: "chevron.forward")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.secondary)
+ .rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
@@ -460,10 +445,9 @@ struct SideNavGroupView: View {
}
.buttonStyle(.plain)
- // Children (animated)
if isExpanded, let children = group.children {
VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(children.enumerated()), id: \.offset) { index, child in
+ ForEach(Array(children.enumerated()), id: \.offset) { _, child in
if let item = child.data {
SideNavItemView(
item: item,
@@ -473,7 +457,7 @@ struct SideNavGroupView: View {
)
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
- removal: .move(edge: .top).combined(with: .opacity)
+ removal: .move(edge: .top).combined(with: .opacity)
))
}
}
diff --git a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
index 84be240..ff71c39 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
@@ -17,6 +17,12 @@ struct NativeTopBar: UIViewRepresentable {
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
+ // RTL: Force right-to-left layout on the navigation bar.
+ // This flips the positions of leftBarButtonItem (moves to right) and
+ // rightBarButtonItems (move to left), and right-aligns the title — all
+ // of which is correct for Arabic/RTL navigation bars.
+ navigationBar.semanticContentAttribute = .forceRightToLeft
+
// Create navigation item
let navItem = UINavigationItem()
navigationBar.items = [navItem]
@@ -35,14 +41,15 @@ struct NativeTopBar: UIViewRepresentable {
func updateUIView(_ navigationBar: UINavigationBar, context: Context) {
guard let topBarData = uiState.topBarData,
- let navItem = navigationBar.items?.first else { return }
+ let navItem = navigationBar.items?.first else { return }
// Update title
if let subtitle = topBarData.subtitle {
// Create attributed title with subtitle
let titleLabel = UILabel()
titleLabel.numberOfLines = 2
- titleLabel.textAlignment = .center
+ // RTL: .natural aligns text to the right in RTL locales automatically.
+ titleLabel.textAlignment = .natural
let titleText = NSMutableAttributedString()
let textColor = topBarData.textColor.flatMap { UIColor(hex: $0) } ?? UIColor.label
@@ -69,7 +76,10 @@ struct NativeTopBar: UIViewRepresentable {
navItem.title = topBarData.title
}
- // Update left bar button (navigation icon)
+ // Update left bar button (navigation/hamburger icon).
+ // RTL note: with semanticContentAttribute = .forceRightToLeft set in makeUIView,
+ // leftBarButtonItem is visually rendered on the RIGHT side of the bar —
+ // which is the correct leading position for RTL Arabic navigation.
if topBarData.showNavigationIcon == true && uiState.hasSideNav() {
let button = UIBarButtonItem(
image: UIImage(systemName: "line.3.horizontal"),
@@ -82,7 +92,9 @@ struct NativeTopBar: UIViewRepresentable {
navItem.leftBarButtonItem = nil
}
- // Update right bar buttons (actions)
+ // Update right bar buttons (actions).
+ // RTL note: rightBarButtonItems will appear on the LEFT side visually, which is
+ // the correct trailing position for action buttons in RTL navigation bars.
if let actions = topBarData.children, !actions.isEmpty {
var barButtonItems: [UIBarButtonItem] = []
@@ -121,12 +133,12 @@ struct NativeTopBar: UIViewRepresentable {
}
if let bgColorHex = topBarData.backgroundColor,
- let bgColor = UIColor(hex: bgColorHex) {
+ let bgColor = UIColor(hex: bgColorHex) {
appearance.backgroundColor = bgColor
}
if let textColorHex = topBarData.textColor,
- let textColor = UIColor(hex: textColorHex) {
+ let textColor = UIColor(hex: textColorHex) {
appearance.titleTextAttributes = [.foregroundColor: textColor]
appearance.largeTitleTextAttributes = [.foregroundColor: textColor]
// Also set the button tint color to match
@@ -164,7 +176,7 @@ struct NativeTopBar: UIViewRepresentable {
@objc func actionTapped(_ sender: UIBarButtonItem) {
guard let actionId = sender.accessibilityIdentifier,
- let url = actionUrls[actionId] else {
+ let url = actionUrls[actionId] else {
return
}
From 888ff3af96a6748340e31fac04a1e094dc446208 Mon Sep 17 00:00:00 2001
From: Adel Ali <3adeling@gmail.com>
Date: Tue, 10 Mar 2026 02:51:49 +0300
Subject: [PATCH 2/4] add RTL support
---
resources/xcode/NativePHP/AppDelegate.swift | 4 +-
resources/xcode/NativePHP/NativePHPApp.swift | 17 +-
.../NativePHP/NativeUI/NativeSideNav.swift | 156 +++++++++---------
3 files changed, 85 insertions(+), 92 deletions(-)
diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift
index c96bc78..9d89674 100644
--- a/resources/xcode/NativePHP/AppDelegate.swift
+++ b/resources/xcode/NativePHP/AppDelegate.swift
@@ -37,8 +37,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
- let locale = Locale.current.language.languageCode?.identifier ?? "en"
- let isRTL = Locale.characterDirection(forLanguage: locale) == .rightToLeft
+ let language = Locale.preferredLanguages.first ?? "en"
+ let isRTL = Locale.characterDirection(forLanguage: language) == .rightToLeft
let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift
index 8430a0b..c346780 100644
--- a/resources/xcode/NativePHP/NativePHPApp.swift
+++ b/resources/xcode/NativePHP/NativePHPApp.swift
@@ -81,8 +81,6 @@ struct NativePHPApp: App {
// It renders underneath the splash until WebView finishes loading
if appState.isReadyToLoad {
ContentView()
- .environment(\.layoutDirection, .rightToLeft)
- .environment(\.locale, Locale(identifier: "ar"))
}
// Splash overlays until WebView finishes loading (Phase 3)
@@ -99,12 +97,15 @@ struct NativePHPApp: App {
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isInitialized)
- // RTL: Apply right-to-left layout direction to the entire SwiftUI view hierarchy.
- // This covers NativeSideNav, SideNavItemView, SideNavGroupView, and all
- // other SwiftUI-based NativeUI components. UIKit components (UITabBar,
- // UINavigationBar) are handled separately via UIView.appearance() in AppDelegate.
- .environment(\.layoutDirection, .rightToLeft)
- .environment(\.locale, Locale(identifier: "ar"))
+ // Apply layout direction based on device locale.
+ // RTL languages (Arabic, Hebrew, etc.) get .rightToLeft; everything else gets .leftToRight.
+ // UIKit components (UITabBar, UINavigationBar) are handled separately
+ // via UIView.appearance() in AppDelegate.
+ .environment(\.layoutDirection, {
+ let language = Locale.preferredLanguages.first ?? "en"
+ return Locale.characterDirection(forLanguage: language) == .rightToLeft
+ ? .rightToLeft : .leftToRight
+ }())
.onOpenURL { url in
// Only handle if not already handled by AppDelegate during cold start
if !DeepLinkRouter.shared.hasPendingURL() {
diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
index 9bda7b1..56855a1 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
@@ -2,21 +2,24 @@ import SwiftUI
/// Bidirectional Side Navigation — supports both LTR and RTL layouts.
///
-/// Reads `layoutDirection` from the SwiftUI environment and adapts automatically:
-/// - LTR: drawer slides in from the LEFT edge
-/// - RTL: drawer slides in from the RIGHT edge
+/// RTL: drawer slides in from the RIGHT edge (positive x hides it)
+/// LTR: drawer slides in from the LEFT edge (negative x hides it)
///
-/// All offset and gesture math is normalised using a `dir` multiplier:
-/// dir = -1 (LTR) → negative x hides drawer off-screen left
-/// dir = +1 (RTL) → positive x hides drawer off-screen right
+/// KEY: SwiftUI's offset(x:) uses SCREEN coordinates and does NOT flip for RTL.
+/// hiddenOffset is therefore direction-aware (positive for RTL, negative for LTR).
///
-/// slideOffset = 0 → drawer fully visible at its edge
-/// slideOffset = drawerWidth * dir → drawer fully hidden off-screen
+/// dragOffset is stored in LOGICAL space (positive = more open, 0 = closed extra).
+/// It is converted to screen coords only when computing drawerXOffset.
struct NativeSideNavigation: View {
@ObservedObject var uiState = NativeUIState.shared
- private var isRTL: Bool { UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft }
+
+ private var isRTL: Bool {
+ let language = Locale.preferredLanguages.first ?? "en"
+ return Locale.characterDirection(forLanguage: language) == .rightToLeft
+ }
+
@State private var expandedGroups: Set = []
- @State private var dragOffset: CGFloat = 0
+ @State private var dragOffset: CGFloat = 0 // logical space: positive = opening
let content: Content
let onNavigate: (String) -> Void
@@ -24,20 +27,6 @@ struct NativeSideNavigation: View {
private let drawerWidthRatio: CGFloat = 0.85
private let edgeSwipeThreshold: CGFloat = 30
- /// +1 for RTL (drawer on right), -1 for LTR (drawer on left).
- /// Multiplying any offset or velocity by this normalises direction math.
- private var dir: CGFloat { isRTL ? 1 : -1 }
-
- /// The physical edge the drawer appears on.
- private var drawerEdge: Edge { isRTL ? .trailing : .leading }
-
- /// Safe area inset on the drawer's side.
- private func safeInset(_ geometry: GeometryProxy) -> CGFloat {
- isRTL
- ? geometry.safeAreaInsets.trailing
- : geometry.safeAreaInsets.leading
- }
-
init(onNavigate: @escaping (String) -> Void, @ViewBuilder content: () -> Content) {
self.onNavigate = onNavigate
self.content = content()
@@ -45,85 +34,84 @@ struct NativeSideNavigation: View {
var body: some View {
GeometryReader { geometry in
- let isLandscape = geometry.size.width > geometry.size.height
- let drawerWidth = geometry.size.width * (isLandscape ? 0.4 : drawerWidthRatio)
- let safeArea = safeInset(geometry)
-
- // Hidden offset: moves drawer fully off-screen on its side.
- // dir * (drawerWidth + safeArea + 10)
- // LTR → negative x → off-screen LEFT
- // RTL → positive x → off-screen RIGHT
- let hiddenOffset = dir * (drawerWidth + safeArea + 10)
-
- // Current drawer x position:
- // open: 0 (drawer at edge, fully visible)
- // closed: hiddenOffset (drawer off-screen)
- let drawerXOffset: CGFloat = {
- let base: CGFloat = uiState.shouldPresentSidebar ? 0 : hiddenOffset
- return base + dragOffset
- }()
+ let screenW = geometry.size.width
+ let screenH = geometry.size.height
+ let isLandscape = screenW > screenH
+ let drawerWidth = screenW * (isLandscape ? 0.4 : drawerWidthRatio)
+
+ // All positioning uses ABSOLUTE screen coordinates via .position().
+ // This is immune to SwiftUI's layoutDirection flipping.
+
+ // slideProgress: 0 = fully open, drawerWidth = fully hidden
+ let baseSlide: CGFloat = uiState.shouldPresentSidebar ? 0 : drawerWidth
+ let slideX: CGFloat = max(0, min(drawerWidth, baseSlide + dragOffset))
+
+ // Drawer center X in screen coordinates:
+ // RTL open: screenW - drawerWidth/2 (right edge)
+ // RTL closed: screenW + drawerWidth/2 (off-screen right)
+ // LTR open: drawerWidth/2 (left edge)
+ // LTR closed: -drawerWidth/2 (off-screen left)
+ let openCenterX: CGFloat = isRTL
+ ? screenW - drawerWidth / 2
+ : drawerWidth / 2
+ let drawerCenterX: CGFloat = isRTL
+ ? openCenterX + slideX
+ : openCenterX - slideX
+ let centerY = screenH / 2
- // Overlay opacity: 0 when hidden, 0.5 when fully open
let overlayOpacity: Double = {
- let progress = 1 - abs(drawerXOffset) / drawerWidth
+ let progress = 1.0 - slideX / drawerWidth
return Double(max(0, min(0.5, progress * 0.5)))
}()
- // Swipe inward from the drawer's edge to open.
- // Inward direction = opposite of dir:
- // LTR: swipe right (+) from left edge
- // RTL: swipe left (-) from right edge
+ // ── Edge swipe to open ──────────────────────────────────────
+ // slideX goes from drawerWidth → 0 when opening.
+ // dragOffset must be NEGATIVE to reduce slideX.
+ // RTL: swipe LEFT from right edge (translation negative) → dragOffset = translation ✓
+ // LTR: swipe RIGHT from left edge (translation positive) → dragOffset = -translation ✓
let edgeSwipeGesture = DragGesture(minimumDistance: 10)
.onChanged { value in
- // Must start from the correct physical edge
let atEdge = isRTL
- ? value.startLocation.x > geometry.size.width - edgeSwipeThreshold
- : value.startLocation.x < edgeSwipeThreshold
-
+ ? value.startLocation.x > screenW - edgeSwipeThreshold
+ : value.startLocation.x < edgeSwipeThreshold
guard atEdge else { return }
- // Normalised translation: negative = inward (opening)
- let norm = dir * value.translation.width
- guard norm < 0 else { return }
-
- // dragOffset mirrors the normalised translation back to screen coords
- // and clamps so drawer can't go past fully open
- dragOffset = max(norm, -abs(hiddenOffset)) * dir
+ let t = value.translation.width
+ guard isRTL ? t < 0 : t > 0 else { return }
+ dragOffset = max(isRTL ? t : -t, -drawerWidth)
}
.onEnded { value in
- let normTranslation = dir * value.translation.width
- let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width)
-
- if abs(normTranslation) > drawerWidth * 0.3 || normVelocity < -300 {
+ let t = abs(value.translation.width)
+ let v = abs(value.predictedEndTranslation.width - value.translation.width)
+ if t > drawerWidth * 0.3 || v > 300 {
open()
} else {
withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 }
}
}
- // Swipe outward on drawer or overlay to close.
- // Outward direction = same as dir:
- // LTR: swipe left (-) → close
- // RTL: swipe right (+) → close
+ // ── Swipe outward to close ──────────────────────────────────
+ // slideX goes from 0 → drawerWidth when closing.
+ // dragOffset must be POSITIVE to increase slideX.
+ // RTL: swipe RIGHT (translation positive) → dragOffset = translation ✓
+ // LTR: swipe LEFT (translation negative) → dragOffset = -translation ✓
let closeDragGesture = DragGesture()
.onChanged { value in
- // Normalised translation: positive = outward (closing)
- let norm = dir * value.translation.width
- guard norm > 0 else { return }
- dragOffset = min(norm, abs(hiddenOffset)) * dir
+ let t = value.translation.width
+ guard isRTL ? t > 0 : t < 0 else { return }
+ dragOffset = min(isRTL ? t : -t, drawerWidth)
}
.onEnded { value in
- let normTranslation = dir * value.translation.width
- let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width)
-
- if normTranslation > drawerWidth * 0.3 || normVelocity > 300 {
+ let t = abs(value.translation.width)
+ let v = abs(value.predictedEndTranslation.width - value.translation.width)
+ if t > drawerWidth * 0.3 || v > 300 {
close()
} else {
withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 }
}
}
- ZStack(alignment: isRTL ? .trailing : .leading) {
+ ZStack {
// ── Main content ──────────────────────────────────────────────
content
.zIndex(0)
@@ -141,16 +129,17 @@ struct NativeSideNavigation: View {
}
// ── Side drawer ───────────────────────────────────────────────
+ // .position() uses absolute screen coordinates — no flipping.
if uiState.hasSideNav() {
drawerContent
- .frame(width: drawerWidth)
+ .frame(width: drawerWidth, height: screenH)
.background(Color(.systemBackground))
- .offset(x: drawerXOffset)
+ .position(x: drawerCenterX, y: centerY)
.zIndex(2)
.gesture(closeDragGesture)
.onAppear {
let side = isRTL ? "RIGHT (RTL)" : "LEFT (LTR)"
- print("📱 NativeSideNavigation: drawer on \(side) edge")
+ print("📱 NativeSideNavigation: drawer on \(side) edge, openCenterX=\(openCenterX)")
if let children = uiState.sideNavData?.children {
for child in children where child.type == "side_nav_group" {
if case .group(let group) = child.data,
@@ -164,11 +153,15 @@ struct NativeSideNavigation: View {
// ── Invisible edge detector for swipe-to-open ────────────────
if uiState.hasSideNav() && !uiState.shouldPresentSidebar {
+ let detectorX = isRTL
+ ? screenW - edgeSwipeThreshold / 2
+ : edgeSwipeThreshold / 2
+
Color.clear
- .frame(width: edgeSwipeThreshold)
+ .frame(width: edgeSwipeThreshold, height: screenH)
.contentShape(Rectangle())
.gesture(edgeSwipeGesture)
- .ignoresSafeArea(edges: isRTL ? .trailing : .leading)
+ .position(x: detectorX, y: centerY)
.zIndex(3)
}
}
@@ -431,8 +424,7 @@ struct SideNavGroupView: View {
}
Text(group.heading).fontWeight(.medium)
Spacer()
- // chevron.forward auto-mirrors with layoutDirection:
- // LTR → points right, RTL → points left
+ // chevron.forward auto-mirrors in RTL (points left in RTL, right in LTR)
Image(systemName: "chevron.forward")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
From 6326b9cfadff12e6bc7727550b936a4392029491 Mon Sep 17 00:00:00 2001
From: Adel Ali <3adeling@gmail.com>
Date: Tue, 10 Mar 2026 22:13:17 +0300
Subject: [PATCH 3/4] add RTL support
---
resources/xcode/NativePHP/NativeUI/NativeSideNav.swift | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
index 56855a1..fb882b6 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
@@ -12,6 +12,7 @@ import SwiftUI
/// It is converted to screen coords only when computing drawerXOffset.
struct NativeSideNavigation: View {
@ObservedObject var uiState = NativeUIState.shared
+ @Environment(\.layoutDirection) private var parentLayoutDirection
private var isRTL: Bool {
let language = Locale.preferredLanguages.first ?? "en"
@@ -114,6 +115,7 @@ struct NativeSideNavigation: View {
ZStack {
// ── Main content ──────────────────────────────────────────────
content
+ .environment(\.layoutDirection, parentLayoutDirection)
.zIndex(0)
.disabled(uiState.shouldPresentSidebar)
@@ -132,6 +134,7 @@ struct NativeSideNavigation: View {
// .position() uses absolute screen coordinates — no flipping.
if uiState.hasSideNav() {
drawerContent
+ .environment(\.layoutDirection, parentLayoutDirection)
.frame(width: drawerWidth, height: screenH)
.background(Color(.systemBackground))
.position(x: drawerCenterX, y: centerY)
@@ -165,6 +168,7 @@ struct NativeSideNavigation: View {
.zIndex(3)
}
}
+ .environment(\.layoutDirection, .leftToRight)
.onChange(of: uiState.shouldPresentSidebar) { _, newValue in
if newValue {
withAnimation(.easeInOut(duration: 0.3)) { dragOffset = 0 }
From edcf3c11c22e17e27ba3042fd20c01edc614a7bc Mon Sep 17 00:00:00 2001
From: Adel Ali <3adeling@gmail.com>
Date: Wed, 11 Mar 2026 00:58:17 +0300
Subject: [PATCH 4/4] enhance RTL support
---
config/nativephp.php | 25 +++++++++++++++++
.../mobile/bridge/functions/EdgeFunctions.kt | 8 ++++++
.../com/nativephp/mobile/ui/MainActivity.kt | 17 +++++++++++-
.../com/nativephp/mobile/ui/NativeUIState.kt | 12 +++++++++
resources/xcode/NativePHP/AppDelegate.swift | 27 ++++++++++---------
.../Bridge/Functions/EdgeFunctions.swift | 12 +++++++++
resources/xcode/NativePHP/ContentView.swift | 4 +++
resources/xcode/NativePHP/Info.plist | 2 --
resources/xcode/NativePHP/NativePHPApp.swift | 12 +++------
.../NativePHP/NativeUI/NativeBottomNav.swift | 11 +++++---
.../NativePHP/NativeUI/NativeSideNav.swift | 3 +--
.../NativePHP/NativeUI/NativeTopBar.swift | 19 ++++++++-----
.../NativePHP/NativeUI/NativeUIState.swift | 16 +++++++++++
src/Commands/BuildIosAppCommand.php | 13 +++++++++
src/Edge/Edge.php | 16 +++++++++--
src/NativeServiceProvider.php | 15 +++++++++++
16 files changed, 175 insertions(+), 37 deletions(-)
diff --git a/config/nativephp.php b/config/nativephp.php
index 4fd28b0..8baf088 100644
--- a/config/nativephp.php
+++ b/config/nativephp.php
@@ -318,6 +318,31 @@
*/
'ipad' => false,
+ /*
+ |--------------------------------------------------------------------------
+ | RTL Support
+ |--------------------------------------------------------------------------
+ |
+ | When enabled, the app will respect the device locale for RTL languages
+ | (Arabic, Hebrew, etc.) and automatically mirror the layout direction.
+ | When disabled (default), the app always uses LTR regardless of locale.
+ |
+ */
+ 'rtl_support' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Localizations
+ |--------------------------------------------------------------------------
+ |
+ | The list of language codes your app supports. These values populate the
+ | CFBundleLocalizations array in Info.plist for iOS builds, telling the
+ | system which localizations your app provides.
+ |
+ */
+
+ 'localizations' => ['en'],
+
/*
|--------------------------------------------------------------------------
| Device Orientation Support
diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt
index 05ef369..8a35f97 100644
--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt
+++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt
@@ -32,6 +32,14 @@ object EdgeFunctions {
return mapOf("error" to "No components provided")
}
+ // Extract _meta.rtl_support flag
+ @Suppress("UNCHECKED_CAST")
+ val meta = parameters["_meta"] as? Map
+ val rtlSupport = meta?.get("rtl_support") as? Boolean
+ if (rtlSupport != null) {
+ NativeUIState.setRtlSupport(rtlSupport)
+ }
+
Log.d("EdgeFunctions.Set", "🎨 Edge.Set called")
try {
diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
index 328a6bb..1d0a916 100644
--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
+++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
@@ -38,6 +38,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -123,7 +124,21 @@ class MainActivity : FragmentActivity(), WebViewProvider {
// Set up Compose UI
setContent {
- MainScreen()
+ val rtlSupport by NativeUIState.rtlSupport
+ val layoutDirection = if (rtlSupport) {
+ // Let the system determine direction from locale
+ androidx.compose.ui.unit.LayoutDirection.Rtl.takeIf {
+ resources.configuration.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL
+ } ?: androidx.compose.ui.unit.LayoutDirection.Ltr
+ } else {
+ androidx.compose.ui.unit.LayoutDirection.Ltr
+ }
+
+ CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalLayoutDirection provides layoutDirection
+ ) {
+ MainScreen()
+ }
}
initializeEnvironmentAsync {
diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt
index 11b5e4c..466aa61 100644
--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt
+++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt
@@ -27,6 +27,10 @@ object NativeUIState {
private val _topBarData = mutableStateOf(null)
val topBarData: State = _topBarData
+ // RTL support flag — controlled by nativephp.rtl_support config
+ private val _rtlSupport = mutableStateOf(false)
+ val rtlSupport: State = _rtlSupport
+
// Keyboard visibility state - used to hide bottom nav when keyboard is open
private val _isKeyboardVisible = mutableStateOf(false)
val isKeyboardVisible: State = _isKeyboardVisible
@@ -35,6 +39,14 @@ object NativeUIState {
var drawerState: DrawerState? = null
var drawerScope: CoroutineScope? = null
+ /**
+ * Update RTL support flag from Edge.Set _meta payload
+ */
+ fun setRtlSupport(enabled: Boolean) {
+ _rtlSupport.value = enabled
+ Log.d(TAG, "RTL support set to: $enabled")
+ }
+
/**
* Update keyboard visibility state
*/
diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift
index 9d89674..8b1af27 100644
--- a/resources/xcode/NativePHP/AppDelegate.swift
+++ b/resources/xcode/NativePHP/AppDelegate.swift
@@ -31,20 +31,11 @@ extension Notification.Name {
class AppDelegate: NSObject, UIApplicationDelegate {
static let shared = AppDelegate()
- // Called when the app is launched
- func application(
- _ application: UIApplication,
- didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
- ) -> Bool {
-
- let language = Locale.preferredLanguages.first ?? "en"
- let isRTL = Locale.characterDirection(forLanguage: language) == .rightToLeft
-
+ /// Apply or remove RTL appearance for all UIKit components.
+ /// Called from NativeUIState when rtlSupport changes.
+ static func applyRTLAppearance(_ isRTL: Bool) {
let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
- // RTL: Force right-to-left layout direction for all UIKit views app-wide.
- // Must be called before any window or view is created so appearance proxies
- // take effect on every UIKit component (UITabBar, UINavigationBar, etc.).
UIView.appearance().semanticContentAttribute = direction
UINavigationBar.appearance().semanticContentAttribute = direction
UITabBar.appearance().semanticContentAttribute = direction
@@ -52,6 +43,18 @@ class AppDelegate: NSObject, UIApplicationDelegate {
UICollectionView.appearance().semanticContentAttribute = direction
UITextField.appearance().textAlignment = .natural
UILabel.appearance().textAlignment = .natural
+ }
+
+ // Called when the app is launched
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+
+ // RTL appearance is applied conditionally via applyRTLAppearance()
+ // when NativeUIState.rtlSupport is set from the Edge.Set payload.
+ // Initial state is LTR until the config flag arrives.
+ AppDelegate.applyRTLAppearance(false)
// Check if the app was launched from a URL (custom scheme)
if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL {
diff --git a/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift b/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift
index 21593eb..66e65d4 100644
--- a/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift
+++ b/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift
@@ -26,6 +26,18 @@ enum EdgeFunctions {
return ["error": "No components array provided"]
}
+ // Extract _meta.rtl_support flag
+ if let meta = parameters["_meta"] as? [String: Any],
+ let rtlSupport = meta["rtl_support"] as? Bool {
+ if Thread.isMainThread {
+ NativeUIState.shared.rtlSupport = rtlSupport
+ } else {
+ DispatchQueue.main.sync {
+ NativeUIState.shared.rtlSupport = rtlSupport
+ }
+ }
+ }
+
print("🎨 Edge.Set called with \(components.count) component(s)")
print("🎨 Edge.Set components: \(components)")
diff --git a/resources/xcode/NativePHP/ContentView.swift b/resources/xcode/NativePHP/ContentView.swift
index 3274b81..8cc357f 100644
--- a/resources/xcode/NativePHP/ContentView.swift
+++ b/resources/xcode/NativePHP/ContentView.swift
@@ -230,6 +230,9 @@ struct WebView: UIViewRepresentable {
let isDarkMode = windowScene?.windows.first?.traitCollection.userInterfaceStyle == .dark
let colorScheme = isDarkMode ? "dark" : "light"
+ let isRTL = NativeUIState.shared.isRTL
+ let dir = isRTL ? "rtl" : "ltr"
+
let js = """
(function() {
// Set CSS variables directly on documentElement for immediate availability
@@ -239,6 +242,7 @@ struct WebView: UIViewRepresentable {
document.documentElement.style.setProperty('--inset-bottom', '\(insets.bottom)px');
document.documentElement.style.setProperty('--inset-left', '\(insets.left)px');
document.documentElement.style.setProperty('--native-color-scheme', '\(colorScheme)');
+ document.documentElement.setAttribute('dir', '\(dir)');
}
})();
"""
diff --git a/resources/xcode/NativePHP/Info.plist b/resources/xcode/NativePHP/Info.plist
index 6ac6450..8e5359f 100644
--- a/resources/xcode/NativePHP/Info.plist
+++ b/resources/xcode/NativePHP/Info.plist
@@ -17,8 +17,6 @@
CFBundleLocalizations
- ar
- en
NSAppTransportSecurity
diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift
index c346780..1a5630a 100644
--- a/resources/xcode/NativePHP/NativePHPApp.swift
+++ b/resources/xcode/NativePHP/NativePHPApp.swift
@@ -97,15 +97,11 @@ struct NativePHPApp: App {
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isInitialized)
- // Apply layout direction based on device locale.
- // RTL languages (Arabic, Hebrew, etc.) get .rightToLeft; everything else gets .leftToRight.
+ // Apply layout direction based on rtl_support config and device locale.
+ // When rtl_support is enabled, RTL languages get .rightToLeft; otherwise always LTR.
// UIKit components (UITabBar, UINavigationBar) are handled separately
- // via UIView.appearance() in AppDelegate.
- .environment(\.layoutDirection, {
- let language = Locale.preferredLanguages.first ?? "en"
- return Locale.characterDirection(forLanguage: language) == .rightToLeft
- ? .rightToLeft : .leftToRight
- }())
+ // via AppDelegate.applyRTLAppearance().
+ .environment(\.layoutDirection, NativeUIState.shared.isRTL ? .rightToLeft : .leftToRight)
.onOpenURL { url in
// Only handle if not already handled by AppDelegate during cold start
if !DeepLinkRouter.shared.hasPendingURL() {
diff --git a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
index bbfd5f4..4c3304b 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift
@@ -27,9 +27,10 @@ struct NativeUITabBar: UIViewRepresentable {
tabBar.scrollEdgeAppearance = appearance
}
- // RTL: Force right-to-left layout so tab items are ordered right-to-left,
- // matching the natural reading direction for Arabic apps.
- tabBar.semanticContentAttribute = .forceRightToLeft
+ // RTL: Conditionally force right-to-left layout so tab items are ordered
+ // right-to-left, matching the natural reading direction for Arabic apps.
+ let isRTL = NativeUIState.shared.isRTL
+ tabBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
// Apply custom active color (tint color for selected items)
if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) {
@@ -96,6 +97,10 @@ struct NativeUITabBar: UIViewRepresentable {
}
func updateUIView(_ tabBar: UITabBar, context: Context) {
+ // Update RTL direction reactively (rtlSupport may change after makeUIView)
+ let isRTL = NativeUIState.shared.isRTL
+ tabBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
+
// Apply custom active color (ensure it persists across updates)
if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) {
tabBar.tintColor = color
diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
index fb882b6..3ef1942 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift
@@ -15,8 +15,7 @@ struct NativeSideNavigation: View {
@Environment(\.layoutDirection) private var parentLayoutDirection
private var isRTL: Bool {
- let language = Locale.preferredLanguages.first ?? "en"
- return Locale.characterDirection(forLanguage: language) == .rightToLeft
+ uiState.isRTL
}
@State private var expandedGroups: Set = []
diff --git a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
index ff71c39..9fb9b99 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift
@@ -17,11 +17,11 @@ struct NativeTopBar: UIViewRepresentable {
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
- // RTL: Force right-to-left layout on the navigation bar.
- // This flips the positions of leftBarButtonItem (moves to right) and
- // rightBarButtonItems (move to left), and right-aligns the title — all
- // of which is correct for Arabic/RTL navigation bars.
- navigationBar.semanticContentAttribute = .forceRightToLeft
+ // RTL: Conditionally force right-to-left layout on the navigation bar.
+ // When enabled, flips leftBarButtonItem to the right and rightBarButtonItems
+ // to the left — correct for Arabic/RTL navigation bars.
+ let isRTL = NativeUIState.shared.isRTL
+ navigationBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
// Create navigation item
let navItem = UINavigationItem()
@@ -40,6 +40,10 @@ struct NativeTopBar: UIViewRepresentable {
}
func updateUIView(_ navigationBar: UINavigationBar, context: Context) {
+ // Update RTL direction reactively (rtlSupport may change after makeUIView)
+ let isRTL = NativeUIState.shared.isRTL
+ navigationBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
+
guard let topBarData = uiState.topBarData,
let navItem = navigationBar.items?.first else { return }
@@ -48,8 +52,9 @@ struct NativeTopBar: UIViewRepresentable {
// Create attributed title with subtitle
let titleLabel = UILabel()
titleLabel.numberOfLines = 2
- // RTL: .natural aligns text to the right in RTL locales automatically.
- titleLabel.textAlignment = .natural
+ // RTL: Force text alignment and semantic direction to match the app's RTL state
+ titleLabel.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
+ titleLabel.textAlignment = isRTL ? .right : .left
let titleText = NSMutableAttributedString()
let textColor = topBarData.textColor.flatMap { UIColor(hex: $0) } ?? UIColor.label
diff --git a/resources/xcode/NativePHP/NativeUI/NativeUIState.swift b/resources/xcode/NativePHP/NativeUI/NativeUIState.swift
index ded40da..98874a5 100644
--- a/resources/xcode/NativePHP/NativeUI/NativeUIState.swift
+++ b/resources/xcode/NativePHP/NativeUI/NativeUIState.swift
@@ -13,6 +13,22 @@ class NativeUIState: ObservableObject {
// Cache to prevent unnecessary updates
private var lastJsonString: String?
+ // RTL support flag — controlled by nativephp.rtl_support config
+ @Published var rtlSupport: Bool = false {
+ didSet {
+ // Re-apply UIKit appearance when rtlSupport changes
+ AppDelegate.applyRTLAppearance(isRTL)
+ }
+ }
+
+ /// Whether the app should use RTL layout direction.
+ /// Returns true only when rtlSupport is enabled AND the device locale is an RTL language.
+ var isRTL: Bool {
+ guard rtlSupport else { return false }
+ let language = Locale.preferredLanguages.first ?? "en"
+ return Locale.characterDirection(forLanguage: language) == .rightToLeft
+ }
+
// Sidebar presentation control (for JavaScript access)
@Published var shouldPresentSidebar = false
diff --git a/src/Commands/BuildIosAppCommand.php b/src/Commands/BuildIosAppCommand.php
index 1b3ba4c..996afd3 100644
--- a/src/Commands/BuildIosAppCommand.php
+++ b/src/Commands/BuildIosAppCommand.php
@@ -514,6 +514,19 @@ private function updateInfoPlistFile(string $filePath, string $appId, ?string $d
// Handle UIBackgroundModes
$this->updateBackgroundModes($rootDict, $plistData, $pushNotificationsEnabled);
+ // Update CFBundleLocalizations from config
+ $localizations = config('nativephp.localizations', ['en']);
+ if (isset($plistData['CFBundleLocalizations'])) {
+ $locArray = $plistData['CFBundleLocalizations']['valueNode'];
+ while ($locArray->firstChild) {
+ $locArray->removeChild($locArray->firstChild);
+ }
+ foreach ($localizations as $locale) {
+ $stringNode = $locArray->ownerDocument->createElement('string', $locale);
+ $locArray->appendChild($stringNode);
+ }
+ }
+
// Handle BIFROST_APP_ID
$bifrostAppId = env('BIFROST_APP_ID');
if ($bifrostAppId) {
diff --git a/src/Edge/Edge.php b/src/Edge/Edge.php
index 9079cbf..910ebe7 100644
--- a/src/Edge/Edge.php
+++ b/src/Edge/Edge.php
@@ -129,7 +129,13 @@ public static function set(): void
}
if (function_exists('nativephp_call')) {
- nativephp_call('Edge.Set', json_encode(['components' => $nativeUIData]));
+ $payload = [
+ 'components' => $nativeUIData,
+ '_meta' => [
+ 'rtl_support' => config('nativephp.rtl_support', false),
+ ],
+ ];
+ nativephp_call('Edge.Set', json_encode($payload));
}
self::reset();
@@ -138,7 +144,13 @@ public static function set(): void
public static function clear(): void
{
if (function_exists('nativephp_call')) {
- nativephp_call('Edge.Set', json_encode(['components' => []]));
+ $payload = [
+ 'components' => [],
+ '_meta' => [
+ 'rtl_support' => config('nativephp.rtl_support', false),
+ ],
+ ];
+ nativephp_call('Edge.Set', json_encode($payload));
}
}
}
diff --git a/src/NativeServiceProvider.php b/src/NativeServiceProvider.php
index 333027e..54ad6e5 100644
--- a/src/NativeServiceProvider.php
+++ b/src/NativeServiceProvider.php
@@ -152,6 +152,21 @@ protected function registerBladeDirectives(): void
Blade::if('android', function () {
return Facades\System::isAndroid();
});
+
+ Blade::directive('nativeHead', function () {
+ return 'getLocale());
+ $__dir = "ltr";
+ if (config("nativephp.rtl_support", false)) {
+ $__rtlLangs = ["ar", "he", "fa", "ur", "ps", "ku", "sd", "yi", "dv", "ckb"];
+ $__langPrefix = explode("-", $__locale)[0];
+ if (in_array($__langPrefix, $__rtlLangs)) {
+ $__dir = "rtl";
+ }
+ }
+ echo "lang=\"{$__locale}\" dir=\"{$__dir}\"";
+ ?>';
+ });
}
protected function registerMiddleware(): void