@applicaster/quick-brick-native-apple 6.16.1 → 6.18.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "QuickBrickApple",
3
- "version": "6.16.1",
3
+ "version": "6.18.0",
4
4
  "platforms": {
5
5
  "ios": "16.0",
6
6
  "tvos": "16.0"
@@ -16,7 +16,7 @@
16
16
  "authors": "Applicaster LTD.",
17
17
  "source": {
18
18
  "git": "https://github.com/applicaster/Zapp-Frameworks.git",
19
- "tag": "@@applicaster/quick-brick-native-apple/6.16.1"
19
+ "tag": "@@applicaster/quick-brick-native-apple/6.18.0"
20
20
  },
21
21
  "requires_arc": true,
22
22
  "source_files": "universal/**/*.{m,swift}",
@@ -0,0 +1,70 @@
1
+ //
2
+ // GroupProxyCoordinator.swift
3
+ // QuickBrickApple
4
+ //
5
+ // Created by Anton Kononenko on 12/22/25.
6
+ //
7
+
8
+ import Foundation
9
+ import React
10
+
11
+ class GroupProxyCoordinator {
12
+ private var commandsQueue: [Command] = []
13
+ private let lock = NSLock()
14
+
15
+ protocol Command {
16
+ var groupId: String { get }
17
+ func canBeApplied() -> Bool
18
+ func apply()
19
+ func cancel()
20
+ }
21
+
22
+ func tryApply(_ command: Command) {
23
+ lock.lock()
24
+ defer { lock.unlock() }
25
+
26
+ if command.canBeApplied() {
27
+ command.apply()
28
+ } else {
29
+ commandsQueue.append(command)
30
+ }
31
+ }
32
+
33
+ private func processQueue() {
34
+ lock.lock()
35
+ let commands = commandsQueue
36
+ commandsQueue.removeAll()
37
+ lock.unlock()
38
+
39
+ for command in commands {
40
+ if command.canBeApplied() {
41
+ command.apply()
42
+ } else {
43
+ lock.lock()
44
+ commandsQueue.append(command)
45
+ lock.unlock()
46
+ }
47
+ }
48
+ }
49
+
50
+ func onGroupBound(groupId _: String) {
51
+ processQueue()
52
+ }
53
+
54
+ func onChildAdded(groupId _: String, itemId _: String) {
55
+ processQueue()
56
+ }
57
+
58
+ func cancelCommandsForGroup(groupId: String) {
59
+ lock.lock()
60
+ defer { lock.unlock() }
61
+
62
+ commandsQueue.removeAll { command in
63
+ if command.groupId == groupId {
64
+ command.cancel()
65
+ return true
66
+ }
67
+ return false
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,105 @@
1
+ //
2
+ // SetItemFocus.swift
3
+ // QuickBrickApple
4
+ //
5
+ // Created by Anton Kononenko on 12/22/25.
6
+ //
7
+
8
+ import Foundation
9
+ import UIKit
10
+
11
+ extension GroupProxyCoordinator {
12
+ class SetItemFocus: Command {
13
+ let groupId: String
14
+ let itemId: String
15
+ let needsForceUpdate: Bool
16
+ let completion: ((Bool) -> Void)?
17
+ weak var manager: GroupProxyManager?
18
+ private var isCancelled = false
19
+ private let lock = NSLock()
20
+
21
+ init(groupId: String,
22
+ itemId: String,
23
+ needsForceUpdate: Bool,
24
+ completion: ((Bool) -> Void)?,
25
+ manager: GroupProxyManager) {
26
+ self.groupId = groupId
27
+ self.itemId = itemId
28
+ self.needsForceUpdate = needsForceUpdate
29
+ self.completion = completion
30
+ self.manager = manager
31
+ }
32
+
33
+ func canBeApplied() -> Bool {
34
+ lock.lock()
35
+ defer { lock.unlock() }
36
+
37
+ if isCancelled {
38
+ return false
39
+ }
40
+
41
+ guard let manager,
42
+ let proxy = manager.getProxy(forGroupId: groupId),
43
+ let group = proxy.getGroup() else {
44
+ return false
45
+ }
46
+
47
+ if group.isFocusDisabled {
48
+ return false
49
+ }
50
+
51
+ if !proxy.isExistingChild(itemId) {
52
+ return false
53
+ }
54
+
55
+ guard let focusableView = proxy.getChild(by: itemId) else {
56
+ return false
57
+ }
58
+
59
+ if focusableView.window == nil || focusableView.frame.isEmpty {
60
+ return false
61
+ }
62
+
63
+ return true
64
+ }
65
+
66
+ func apply() {
67
+ lock.lock()
68
+ if isCancelled {
69
+ lock.unlock()
70
+ completion?(false)
71
+ return
72
+ }
73
+ lock.unlock()
74
+
75
+ guard let manager,
76
+ let proxy = manager.getProxy(forGroupId: groupId),
77
+ let groupView = proxy.getGroup(),
78
+ let focusableView = proxy.getChild(by: itemId) else {
79
+ completion?(false)
80
+ return
81
+ }
82
+
83
+ manager.setForceFocusUpdateData(itemId: itemId, groupId: groupId)
84
+
85
+ DispatchQueue.main.async { [weak manager] in
86
+ groupView.forceFocus(
87
+ view: focusableView,
88
+ needsForceUpdate: self.needsForceUpdate,
89
+ callback: { success in
90
+ manager?.clearForceFocusUpdate()
91
+ self.completion?(success)
92
+ }
93
+ )
94
+ }
95
+ }
96
+
97
+ func cancel() {
98
+ lock.lock()
99
+ isCancelled = true
100
+ lock.unlock()
101
+
102
+ completion?(false)
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,90 @@
1
+ //
2
+ // SetPreferredFocus.swift
3
+ // QuickBrickApple
4
+ //
5
+ // Created by Anton Kononenko on 12/23/25.
6
+ //
7
+
8
+ import Foundation
9
+ import UIKit
10
+
11
+ extension GroupProxyCoordinator {
12
+ class SetPreferredFocus: Command {
13
+ let groupId: String
14
+ let itemId: String
15
+ let isPreferred: Bool
16
+ weak var manager: GroupProxyManager?
17
+ private var isCancelled = false
18
+ private let lock = NSLock()
19
+
20
+ init(groupId: String,
21
+ itemId: String,
22
+ isPreferred: Bool,
23
+ manager: GroupProxyManager) {
24
+ self.groupId = groupId
25
+ self.itemId = itemId
26
+ self.isPreferred = isPreferred
27
+ self.manager = manager
28
+ }
29
+
30
+ func canBeApplied() -> Bool {
31
+ lock.lock()
32
+ defer { lock.unlock() }
33
+
34
+ if isCancelled {
35
+ return false
36
+ }
37
+
38
+ guard let manager,
39
+ let proxy = manager.getProxy(forGroupId: groupId),
40
+ let group = proxy.getGroup() else {
41
+ return false
42
+ }
43
+
44
+ if !proxy.isExistingChild(itemId) {
45
+ return false
46
+ }
47
+
48
+ guard let focusableView = proxy.getChild(by: itemId) else {
49
+ return false
50
+ }
51
+
52
+ if focusableView.window == nil || focusableView.frame.isEmpty {
53
+ return false
54
+ }
55
+
56
+ return true
57
+ }
58
+
59
+ func apply() {
60
+ lock.lock()
61
+ if isCancelled {
62
+ lock.unlock()
63
+ return
64
+ }
65
+ lock.unlock()
66
+
67
+ guard let manager,
68
+ let proxy = manager.getProxy(forGroupId: groupId),
69
+ let group = proxy.getGroup(),
70
+ let focusableView = proxy.getChild(by: itemId) else {
71
+ return
72
+ }
73
+
74
+ DispatchQueue.main.async {
75
+ if self.isPreferred {
76
+ group.setUserPreferredFocusEnvironments([focusableView])
77
+ } else if let currentPreferred = group.getUserPreferredFocusEnvironments()?.first as? FocusableView,
78
+ currentPreferred === focusableView {
79
+ group.setUserPreferredFocusEnvironments(nil)
80
+ }
81
+ }
82
+ }
83
+
84
+ func cancel() {
85
+ lock.lock()
86
+ isCancelled = true
87
+ lock.unlock()
88
+ }
89
+ }
90
+ }
@@ -15,7 +15,7 @@ let kFocusableManagerModule = "FocusableManagerModule"
15
15
  class FocusableManagerModule: NSObject, RCTBridgeModule {
16
16
  /// Delay timer before focus will be invoked, was done to resolve async problems.
17
17
  /// Sometimes focusale item did has superview when it render that may cayse glitches.
18
- var delayTimer = 100
18
+ var delayTimer = 0
19
19
 
20
20
  /// main React bridge
21
21
  var bridge: RCTBridge?
@@ -58,7 +58,6 @@ class FocusableManagerModule: NSObject, RCTBridgeModule {
58
58
  @objc func forceFocus(_ groupId: String?, itemId: String?, callback: RCTResponseSenderBlock?) {
59
59
  // We need delay to make sure that on native side group will have superview
60
60
  let delay = DispatchTime.now() + DispatchTimeInterval.milliseconds(delayTimer)
61
-
62
61
  DispatchQueue.main.asyncAfter(deadline: delay) {
63
62
  GroupProxyManager.shared.updateFocus(groupId: groupId,
64
63
  itemId: itemId,
@@ -11,50 +11,80 @@ import XrayLogger
11
11
  class GroupProxy {
12
12
  let groupId: String
13
13
  private weak var group: FocusableGroupView?
14
- private var children: [WeakBox<FocusableView>] = []
14
+ private weak var coordinator: GroupProxyCoordinator?
15
+ private var children: [String: WeakBox<FocusableView>] = [:]
15
16
  private let lock = NSLock()
16
17
 
17
- var delayedPreferredFocus: FocusableView?
18
-
19
18
  private lazy var logger = Logger.getLogger(for: "QuickBrickApple/FocusableGroupManager/GroupProxy")
20
19
 
21
- init(groupId: String) {
20
+ init(groupId: String, coordinator: GroupProxyCoordinator) {
22
21
  self.groupId = groupId
22
+ self.coordinator = coordinator
23
+ }
24
+
25
+ func getChild(by itemId: String) -> FocusableView? {
26
+ lock.lock()
27
+ defer { lock.unlock() }
28
+
29
+ return children[itemId]?.value
23
30
  }
24
31
 
25
32
  // MARK: - Child Management
26
33
 
27
34
  func addChild(_ view: FocusableView) {
28
35
  lock.lock()
29
- children.append(WeakBox(view))
36
+
37
+ guard let itemId = view.itemId else {
38
+ lock.unlock()
39
+ return
40
+ }
41
+
42
+ children[itemId] = WeakBox(view)
30
43
  lock.unlock()
44
+
45
+ view.sendViewRegisteredEvent()
46
+
47
+ coordinator?.onChildAdded(groupId: groupId, itemId: itemId)
31
48
  }
32
49
 
33
50
  func removeChild(_ view: FocusableView) {
34
51
  lock.lock()
35
- defer { lock.unlock() }
36
52
 
37
- children.removeAll { $0.value === view }
53
+ if let itemId = view.itemId {
54
+ children.removeValue(forKey: itemId)
55
+ }
38
56
 
39
- if children.isEmpty, group == nil {
57
+ let shouldRemoveProxy = children.isEmpty && group == nil
58
+ lock.unlock()
59
+
60
+ if shouldRemoveProxy {
61
+ coordinator?.cancelCommandsForGroup(groupId: groupId)
40
62
  GroupProxyManager.shared.removeProxy(forGroupId: groupId)
41
63
  }
64
+ }
42
65
 
43
- if delayedPreferredFocus === view {
44
- delayedPreferredFocus = nil
45
- }
66
+ func isExistingChild(_ itemId: String) -> Bool {
67
+ lock.lock()
68
+ defer { lock.unlock() }
69
+ return children[itemId]?.value != nil
46
70
  }
47
71
 
48
72
  // MARK: - Group Binding
49
73
 
50
74
  @MainActor func bindGroup(_ group: FocusableGroupView) {
51
- // Check for duplicate group binding
52
- if let existingGroup = self.group {
75
+ lock.lock()
76
+ let existingGroup = self.group
77
+ lock.unlock()
78
+
79
+ if let existingGroup, existingGroup !== group {
53
80
  logger?.warningLog(message: """
54
81
  ⚠️ Duplicate group binding detected for groupId: '\(groupId)'
55
82
  Previous group: \(existingGroup.debugDescription)
56
83
  New group: \(group.debugDescription)
57
- Overwriting previous binding.
84
+ identifier1: \(ObjectIdentifier(existingGroup))
85
+ identifier2: \(ObjectIdentifier(group))
86
+ Previous group will be unable to unbind (React Native created duplicate itemIds).
87
+ Old group's cleanup() will handle releasing its proxy reference.
58
88
  """)
59
89
 
60
90
  #if DEBUG
@@ -62,53 +92,63 @@ class GroupProxy {
62
92
  #endif
63
93
  }
64
94
 
95
+ lock.lock()
65
96
  self.group = group
97
+ lock.unlock()
66
98
 
67
- if let preferredFocusView = delayedPreferredFocus {
68
- applyPreferredFocus(group,
69
- view: preferredFocusView)
70
- delayedPreferredFocus = nil
71
- }
99
+ group.sendGroupRegisteredEvent()
100
+
101
+ coordinator?.onGroupBound(groupId: groupId)
72
102
  }
73
103
 
74
- func unbindGroup() {
104
+ func unbindGroup(expectedGroup: FocusableGroupView) {
105
+ if let group,
106
+ group !== expectedGroup {
107
+ logger?.warningLog(message: """
108
+ ⚠️ Mismatched group unbinding detected for groupId: '\(groupId)'
109
+ Expected group: \(expectedGroup.debugDescription)
110
+ Current bound group: \(String(describing: group.debugDescription))
111
+ identifier1: \(ObjectIdentifier(expectedGroup))
112
+ identifier2: \(ObjectIdentifier(group)))
113
+ Skipping unbind.
114
+ """)
115
+
116
+ return
117
+ }
118
+
75
119
  lock.lock()
76
- defer { lock.unlock() }
120
+ let shouldRemoveProxy = children.isEmpty
121
+ lock.unlock()
77
122
 
78
- if children.isEmpty {
123
+ if shouldRemoveProxy {
124
+ coordinator?.cancelCommandsForGroup(groupId: groupId)
79
125
  GroupProxyManager.shared.removeProxy(forGroupId: groupId)
80
126
  }
81
127
 
128
+ lock.lock()
82
129
  group = nil
130
+ lock.unlock()
83
131
  }
84
132
 
85
133
  // MARK: - Preferred Focus
86
134
 
87
- @MainActor private func applyPreferredFocus(_ currentGroup: FocusableGroupView, view: FocusableView) {
88
- if view.preferredFocus {
89
- currentGroup.setUserPreferredFocusEnvironments([view])
90
- } else if let currentPreferred = currentGroup.getUserPreferredFocusEnvironments()?.first as? FocusableView,
91
- currentPreferred === view {
92
- currentGroup.setUserPreferredFocusEnvironments(nil)
135
+ @MainActor func updatePreferredFocus(_ view: FocusableView) {
136
+ guard let itemId = view.itemId else {
137
+ return
93
138
  }
94
- }
95
139
 
96
- @MainActor func updatePreferredFocus(_ view: FocusableView) {
97
- lock.lock()
98
- let currentGroup = group
99
- lock.unlock()
140
+ coordinator?.cancelCommandsForGroup(groupId: groupId)
100
141
 
101
- if let currentGroup {
102
- applyPreferredFocus(currentGroup,
103
- view: view)
104
- } else {
105
- if view.preferredFocus {
106
- delayedPreferredFocus = view
107
- }
108
- }
142
+ let command = GroupProxyCoordinator.SetPreferredFocus(
143
+ groupId: groupId,
144
+ itemId: itemId,
145
+ isPreferred: view.preferredFocus,
146
+ manager: GroupProxyManager.shared
147
+ )
148
+
149
+ coordinator?.tryApply(command)
109
150
  }
110
151
 
111
- /// Get the bound group (if any)
112
152
  func getGroup() -> FocusableGroupView? {
113
153
  lock.lock()
114
154
  defer { lock.unlock() }
@@ -7,13 +7,14 @@
7
7
 
8
8
  import Foundation
9
9
  import React
10
+ import ZappCore
10
11
 
11
12
  class GroupProxyManager {
12
13
  static let shared = GroupProxyManager()
13
14
 
14
15
  private let lock = NSLock()
15
- private var proxies: [GroupProxy] = []
16
-
16
+ private var proxies: [String: GroupProxy] = [:]
17
+ private var coordinator: GroupProxyCoordinator = .init()
17
18
  private init() {}
18
19
 
19
20
  // MARK: - Proxy Access
@@ -22,26 +23,89 @@ class GroupProxyManager {
22
23
  lock.lock()
23
24
  defer { lock.unlock() }
24
25
 
25
- if let existing = proxies.first(where: { $0.groupId == groupId }) {
26
+ if let existing = proxies[groupId] {
26
27
  return existing
27
28
  }
28
29
 
29
- let proxy = GroupProxy(groupId: groupId)
30
- proxies.append(proxy)
30
+ let proxy = GroupProxy(groupId: groupId, coordinator: coordinator)
31
+ proxies[groupId] = proxy
31
32
  return proxy
32
33
  }
33
34
 
35
+ func getProxy(forGroupId groupId: String) -> GroupProxy? {
36
+ lock.lock()
37
+ defer { lock.unlock() }
38
+
39
+ return proxies[groupId]
40
+ }
41
+
42
+ func hasProxy(forGroupId groupId: String) -> Bool {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+
46
+ return proxies[groupId] != nil
47
+ }
48
+
34
49
  // MARK: - Cleanup
35
50
 
36
51
  func removeProxy(forGroupId groupId: String) {
37
52
  lock.lock()
38
53
  defer { lock.unlock() }
39
54
 
40
- proxies.removeAll { $0.groupId == groupId }
55
+ coordinator.cancelCommandsForGroup(groupId: groupId)
56
+ proxies.removeValue(forKey: groupId)
41
57
  }
42
58
 
43
59
  // MARK: - Focus Update
44
60
 
61
+ private var forceFocusUpdateData: (itemId: String, groupId: String)?
62
+
63
+ func setForceFocusUpdateData(itemId: String, groupId: String) {
64
+ lock.lock()
65
+ forceFocusUpdateData = (itemId: itemId, groupId: groupId)
66
+ lock.unlock()
67
+ }
68
+
69
+ func clearForceFocusUpdate() {
70
+ lock.lock()
71
+ forceFocusUpdateData = nil
72
+ lock.unlock()
73
+ }
74
+
75
+ func focusableViewCanBecomeFocusableForceFocusUpdate(groupId: String?, itemId: String?) -> Bool? {
76
+ lock.lock()
77
+ defer { lock.unlock() }
78
+
79
+ guard let groupId,
80
+ let itemId,
81
+ let forceFocusData = forceFocusUpdateData else {
82
+ return nil
83
+ }
84
+
85
+ if groupId == forceFocusData.groupId,
86
+ itemId == forceFocusData.itemId {
87
+ return true
88
+ }
89
+
90
+ return false
91
+ }
92
+
93
+ func focusableGroupCanBecomeFocusableForceFocusUpdate(groupId: String?) -> Bool? {
94
+ lock.lock()
95
+ defer { lock.unlock() }
96
+
97
+ guard let groupId,
98
+ let forceFocusData = forceFocusUpdateData else {
99
+ return nil
100
+ }
101
+
102
+ if groupId == forceFocusData.groupId {
103
+ return true
104
+ }
105
+
106
+ return false
107
+ }
108
+
45
109
  func updateFocus(groupId: String?,
46
110
  itemId: String?,
47
111
  needsForceUpdate: Bool = false,
@@ -52,50 +116,16 @@ class GroupProxyManager {
52
116
  return
53
117
  }
54
118
 
55
- lock.lock()
56
- let proxy = proxies.first(where: { $0.groupId == groupId })
57
- lock.unlock()
119
+ coordinator.cancelCommandsForGroup(groupId: groupId)
58
120
 
59
- guard let proxy,
60
- let groupView = proxy.getGroup() else {
61
- completion?(false)
62
- return
63
- }
121
+ let command = GroupProxyCoordinator.SetItemFocus(
122
+ groupId: groupId,
123
+ itemId: itemId,
124
+ needsForceUpdate: needsForceUpdate,
125
+ completion: completion,
126
+ manager: self
127
+ )
64
128
 
65
- DispatchQueue.main.async {
66
- var rootView: UIView? = groupView
67
-
68
- while let unwrapedRootView = rootView,
69
- !unwrapedRootView.isReactRootView() {
70
- rootView = rootView?.superview
71
- }
72
-
73
- guard let rootViewUnwrapped = rootView,
74
- let superView = rootViewUnwrapped.superview as? RCTRootView else {
75
- completion?(false)
76
- return
77
- }
78
-
79
- if needsForceUpdate {
80
- superView.setNeedsFocusUpdate()
81
- superView.updateFocusIfNeeded()
82
-
83
- let timeout: TimeInterval = 2.0
84
-
85
- groupView.didFocusCallBack = (completion: {
86
- completion?(true)
87
- groupView.didFocusCallBack = nil
88
- }, focusableItemId: itemId)
89
-
90
- DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
91
- if groupView.didFocusCallBack != nil {
92
- completion?(false)
93
- groupView.didFocusCallBack = nil
94
- }
95
- }
96
- } else {
97
- completion?(true)
98
- }
99
- }
129
+ coordinator.tryApply(command)
100
130
  }
101
131
  }
@@ -18,6 +18,7 @@ RCT_EXTERN_METHOD(setPreferredFocus:(NSString *)groupId itemId:(NSString *)itemI
18
18
  @end
19
19
 
20
20
  @interface RCT_EXTERN_MODULE(FocusableGroupViewModule, RCTViewManager)
21
+ RCT_EXPORT_VIEW_PROPERTY(onRegistered, RCTDirectEventBlock)
21
22
  RCT_EXPORT_VIEW_PROPERTY(onGroupFocus, RCTDirectEventBlock)
22
23
  RCT_EXPORT_VIEW_PROPERTY(onGroupBlur, RCTDirectEventBlock)
23
24
  RCT_EXPORT_VIEW_PROPERTY(itemId, NSString);
@@ -27,6 +28,7 @@ RCT_EXPORT_VIEW_PROPERTY(isFocusDisabled, BOOL);
27
28
  @end
28
29
 
29
30
  @interface RCT_EXTERN_MODULE(FocusableViewModule, RCTViewManager)
31
+ RCT_EXPORT_VIEW_PROPERTY(onRegistered, RCTDirectEventBlock)
30
32
  RCT_EXPORT_VIEW_PROPERTY(itemId, NSString);
31
33
  RCT_EXPORT_VIEW_PROPERTY(groupId, NSString);
32
34
  RCT_EXPORT_VIEW_PROPERTY(forceFocus, BOOL)
@@ -20,10 +20,7 @@ typealias NotifierActionType = (_ type: FocusableGroupNotifierActionType, _ cont
20
20
  class FocusableGroupStateNotifierDefault: FocusableGroupStateNotifier {
21
21
  private(set) var isActive: Bool = false
22
22
 
23
- let action: NotifierActionType
24
- init(action: @escaping NotifierActionType) {
25
- self.action = action
26
- }
23
+ var action: NotifierActionType?
27
24
 
28
25
  func updateFocus(currentlyActive: Bool, context: UIFocusUpdateContext) {
29
26
  guard currentlyActive != isActive else { return }
@@ -33,6 +30,6 @@ class FocusableGroupStateNotifierDefault: FocusableGroupStateNotifier {
33
30
  }
34
31
 
35
32
  func sendNotifierEvent(context: UIFocusUpdateContext) {
36
- action(isActive ? .onFocus : .onBlur, context)
33
+ action?(isActive ? .onFocus : .onBlur, context)
37
34
  }
38
35
  }
@@ -19,6 +19,7 @@ public class FocusableGroupView: RCTTVView {
19
19
  let preferredFocusDisabledDesc = isPreferredFocusDisabled ? "preferred-focus-disabled" : "preferred-focus-enabled"
20
20
  let dependentGroupsDesc = dependantGroupIds?.joined(separator: ",") ?? "NO_DEPS"
21
21
  let reactTagDesc = reactTag?.stringValue ?? "NO_TAG"
22
+ let preferredFocusEnvDesc = preferredFocusEnvironments.first.map { String(describing: $0) } ?? "N/A"
22
23
 
23
24
  return """
24
25
  FocusableGroupView[
@@ -28,16 +29,58 @@ public class FocusableGroupView: RCTTVView {
28
29
  STATE: \(activeStateDesc), \(focusDisabledDesc), \(preferredFocusDisabledDesc)
29
30
  DEPS: \(dependentGroupsDesc)
30
31
  FRAME: \(frame)
32
+ PREFFERED_FOCUS_ENVIRONMENTS: \(preferredFocusEnvDesc)
31
33
  ]
32
34
  """
33
35
  }
34
36
 
35
- public var didFocusCallBack: (completion: () -> Void, focusableItemId: String)?
37
+ public private(set) var isPresented: Bool = false
38
+ private var focusTimer: Timer?
36
39
 
37
- private lazy var activeStateNotifier: FocusableGroupStateNotifier = FocusableGroupStateNotifierDefault(action: notifyReactNativeFocusUpdate)
40
+ override public func didMoveToWindow() {
41
+ super.didMoveToWindow()
42
+ updateIsPresented()
43
+ }
44
+
45
+ override public func layoutSubviews() {
46
+ super.layoutSubviews()
47
+ updateIsPresented()
48
+ }
49
+
50
+ private func updateIsPresented() {
51
+ isPresented =
52
+ window != nil &&
53
+ bounds.width > 0 &&
54
+ bounds.height > 0 &&
55
+ !isHidden &&
56
+ alpha > 0.01
57
+ registerGroup()
58
+ }
59
+
60
+ private func registerGroup() {
61
+ guard let itemId,
62
+ isPresented,
63
+ isGroupRegistered == false
64
+ else {
65
+ return
66
+ }
67
+
68
+ activeStateNotifier.action = { [weak self] type, context in
69
+ self?.notifyReactNativeFocusUpdate(type, context)
70
+ }
71
+
72
+ proxy = GroupProxyManager.shared.getOrCreateProxy(forGroupId: itemId)
73
+ proxy?.bindGroup(self)
74
+ isGroupRegistered = true
75
+ }
76
+
77
+ public var didForceFocusCallBack: ((Bool) -> Void)?
78
+
79
+ private let activeStateNotifier = FocusableGroupStateNotifierDefault()
38
80
 
39
81
  @objc public var onGroupFocus: RCTDirectEventBlock?
40
82
  @objc public var onGroupBlur: RCTDirectEventBlock?
83
+ @objc public var onRegistered: RCTDirectEventBlock?
41
84
 
42
85
  @objc public var isFocusDisabled: Bool = false
43
86
 
@@ -48,24 +91,33 @@ public class FocusableGroupView: RCTTVView {
48
91
  isWithMemory == false
49
92
  }
50
93
 
94
+ private var isGroupRegistered: Bool = false
95
+
51
96
  @objc public var itemId: String? {
52
97
  didSet {
53
98
  if itemId != oldValue {
54
99
  if oldValue != nil {
55
- proxy?.unbindGroup()
100
+ proxy?.unbindGroup(expectedGroup: self)
56
101
  proxy = nil
102
+ isGroupRegistered = false
57
103
  }
58
104
 
59
- if let newId = itemId {
60
- proxy = GroupProxyManager.shared.getOrCreateProxy(forGroupId: newId)
61
- proxy?.bindGroup(self)
62
- }
105
+ registerGroup()
63
106
  }
64
107
  }
65
108
  }
66
109
 
67
110
  @objc public var groupId: String?
68
111
 
112
+ func sendGroupRegisteredEvent() {
113
+ var params: [String: Any] = [:]
114
+ params[GroupViewUpdateEvents.preferredFocusEnvironment] = dataForFocusItem(focusItem: preferredFocusEnvironments.first)
115
+ params[GroupViewUpdateEvents.groupId] = groupId
116
+ params[GroupViewUpdateEvents.isFocusDisabled] = isFocusDisabled
117
+ params[GroupViewUpdateEvents.itemId] = itemId
118
+ onRegistered?(params)
119
+ }
120
+
69
121
  @MainActor private var userPreferredFocusEnvironments: [UIFocusEnvironment]?
70
122
  @MainActor private var customPreferredFocusEnvironment: [UIFocusEnvironment]?
71
123
 
@@ -86,16 +138,79 @@ public class FocusableGroupView: RCTTVView {
86
138
  }
87
139
 
88
140
  deinit {
141
+ focusTimer?.invalidate()
142
+ focusTimer = nil
89
143
  cleanup()
90
144
  }
91
145
 
146
+ @MainActor public func forceFocus(view: FocusableView,
147
+ needsForceUpdate: Bool,
148
+ callback completion: ((Bool) -> Void)?) {
149
+ updatePreferredFocusEnv(with: view)
150
+
151
+ if !needsForceUpdate {
152
+ completion?(true)
153
+ return
154
+ }
155
+
156
+ guard let rootView = UIApplication.shared.rootViewController()?.view as? RCTRootView else {
157
+ completion?(false)
158
+ return
159
+ }
160
+
161
+ guard itemId != nil else {
162
+ completion?(false)
163
+ return
164
+ }
165
+
166
+ focusTimer?.invalidate()
167
+ focusTimer = nil
168
+
169
+ didForceFocusCallBack = { [weak self] success in
170
+ guard let self else {
171
+ completion?(false)
172
+ return
173
+ }
174
+
175
+ focusTimer?.invalidate()
176
+ focusTimer = nil
177
+
178
+ completion?(success)
179
+ didForceFocusCallBack = nil
180
+ }
181
+
182
+ let cancelationTimeout: TimeInterval = 1
183
+ focusTimer?.invalidate()
184
+ focusTimer = Timer.scheduledTimer(withTimeInterval: cancelationTimeout, repeats: false) { [weak self] _ in
185
+ guard let self else {
186
+ completion?(false)
187
+ return
188
+ }
189
+
190
+ if let callback = didForceFocusCallBack {
191
+ callback(false)
192
+ didForceFocusCallBack = nil
193
+ }
194
+
195
+ focusTimer = nil
196
+ }
197
+
198
+ rootView.setNeedsFocusUpdate()
199
+ rootView.updateFocusIfNeeded()
200
+ }
201
+
92
202
  private func cleanup() {
93
- guard let itemId else {
203
+ guard itemId != nil else {
94
204
  return
95
205
  }
96
206
 
97
- proxy?.unbindGroup()
207
+ activeStateNotifier.action = nil
208
+ customPreferredFocusEnvironment = nil
209
+ userPreferredFocusEnvironments = nil
210
+
211
+ proxy?.unbindGroup(expectedGroup: self)
98
212
  proxy = nil
213
+ isGroupRegistered = false
99
214
  }
100
215
 
101
216
  public required init?(coder aDecoder: NSCoder) {
@@ -234,7 +349,9 @@ public class FocusableGroupView: RCTTVView {
234
349
  // MARK: Focus Engine
235
350
 
236
351
  override public var preferredFocusEnvironments: [UIFocusEnvironment] {
237
- customPreferredFocusEnvironment ?? userPreferredFocusEnvironments ?? super.preferredFocusEnvironments
352
+ customPreferredFocusEnvironment ??
353
+ userPreferredFocusEnvironments ??
354
+ super.preferredFocusEnvironments
238
355
  }
239
356
 
240
357
  override public func shouldUpdateFocus(in _: UIFocusUpdateContext) -> Bool {
@@ -248,36 +365,19 @@ public class FocusableGroupView: RCTTVView {
248
365
  updatePreferredFocusView(nextFocusItem: context.nextFocusedItem)
249
366
  }
250
367
 
368
+ didForceFocusCallBack?(true)
369
+
251
370
  let isActive = focusItemIsDescendant(nextFocusItem: context.nextFocusedItem)
252
371
  activeStateNotifier.updateFocus(currentlyActive: isActive, context: context)
253
-
254
- tryDidFocusCallCallback(context: context)
255
- }
256
-
257
- /// Try to send a callback in case focus manager request callback during force focus update
258
- ///
259
- /// - Parameter context: An instance of UIFocusUpdateContext containing metadata of the focus related update.
260
- private func tryDidFocusCallCallback(context: UIFocusUpdateContext) {
261
- guard let callbackData = didFocusCallBack,
262
- let fousedView = context.nextFocusedView as? FocusableView,
263
- let itemId = fousedView.itemId
264
- else {
265
- return
266
- }
267
-
268
- let completion = callbackData.completion
269
- let focusableItemId = callbackData.focusableItemId
270
- if itemId == focusableItemId {
271
- DispatchQueue.main.asyncAfter(deadline: .now()) {
272
- completion()
273
- }
274
- }
275
- didFocusCallBack = nil
276
372
  }
277
373
  }
278
374
 
279
375
  extension FocusableGroupView: FocusableGroupProtocol {
280
376
  var canBecomeFocusable: Bool {
281
- !isFocusDisabled
377
+ if let forceFocusEnabled = GroupProxyManager.shared.focusableGroupCanBecomeFocusableForceFocusUpdate(groupId: itemId) {
378
+ return forceFocusEnabled
379
+ }
380
+
381
+ return !isFocusDisabled
282
382
  }
283
383
  }
@@ -35,6 +35,32 @@ public class FocusableView: ParallaxView {
35
35
  """
36
36
  }
37
37
 
38
+ public private(set) var isPresented: Bool = false
39
+ @objc public var onRegistered: RCTDirectEventBlock?
40
+
41
+ func sendViewRegisteredEvent() {
42
+ var params: [String: Any] = [:]
43
+ params[GroupViewUpdateEvents.groupId] = groupId
44
+ params[GroupViewUpdateEvents.isFocusDisabled] = !canBecomeFocused
45
+ params[GroupViewUpdateEvents.itemId] = itemId
46
+ onRegistered?(params)
47
+ }
48
+
49
+ override public func didMoveToWindow() {
50
+ super.didMoveToWindow()
51
+ updateIsPresented()
52
+ }
53
+
54
+ private func updateIsPresented() {
55
+ isPresented =
56
+ window != nil &&
57
+ bounds.width > 0 &&
58
+ bounds.height > 0 &&
59
+ !isHidden &&
60
+ alpha > 0.01
61
+ registerView()
62
+ }
63
+
38
64
  private weak var module: FocusableViewModule?
39
65
  func setModule(_ module: FocusableViewModule) {
40
66
  self.module = module
@@ -115,7 +141,7 @@ public class FocusableView: ParallaxView {
115
141
 
116
142
  override public func layoutSubviews() {
117
143
  super.layoutSubviews()
118
-
144
+ updateIsPresented()
119
145
  if isFocusLayoutConfigured == false {
120
146
  configureFocusLayout()
121
147
  isFocusLayoutConfigured = true
@@ -125,6 +151,7 @@ public class FocusableView: ParallaxView {
125
151
  private func registerView() {
126
152
  guard let groupId,
127
153
  itemId != nil,
154
+ isPresented,
128
155
  isViewRegistered == false
129
156
  else {
130
157
  return
@@ -227,6 +254,10 @@ public class FocusableView: ParallaxView {
227
254
  return focusable
228
255
  }
229
256
 
257
+ if let forceFocusEnabled = GroupProxyManager.shared.focusableViewCanBecomeFocusableForceFocusUpdate(groupId: groupId, itemId: itemId) {
258
+ return forceFocusEnabled
259
+ }
260
+
230
261
  return focusableGroup.canBecomeFocusable && focusable
231
262
  }
232
263
  }
@@ -0,0 +1,77 @@
1
+ //
2
+ // ContextResolverBridge.swift
3
+ // QuickBrickApple
4
+ //
5
+ // Created by Alex Zchut on 15/01/2026.
6
+ // Copyright © 2026 Applicaster LTD. All rights reserved.
7
+ //
8
+
9
+ import Foundation
10
+ import React
11
+ import ZappCore
12
+
13
+ @objc(ContextResolverBridge)
14
+ class ContextResolverBridge: NSObject, RCTBridgeModule {
15
+ static func moduleName() -> String! {
16
+ "ContextResolverBridge"
17
+ }
18
+
19
+ class func requiresMainQueueSetup() -> Bool {
20
+ true
21
+ }
22
+
23
+ /// prefered thread on which to run this native module
24
+ @objc var methodQueue: DispatchQueue {
25
+ DispatchQueue.main
26
+ }
27
+
28
+ @objc func resolveContextKeys(_ keys: [String: Any],
29
+ resolver: @escaping RCTPromiseResolveBlock,
30
+ rejecter _: @escaping RCTPromiseRejectBlock) {
31
+ var result: [String: Any] = [:]
32
+
33
+ for (key, value) in keys {
34
+ let isRequired = extractBoolValue(from: value)
35
+ let resolved = resolveStorage(key: key, required: isRequired)
36
+ result[key] = resolved ?? NSNull()
37
+ }
38
+
39
+ resolver(result)
40
+ }
41
+
42
+ private func extractBoolValue(from value: Any) -> Bool {
43
+ if let number = value as? NSNumber {
44
+ number.boolValue
45
+ } else if let boolValue = value as? Bool {
46
+ boolValue
47
+ } else {
48
+ false
49
+ }
50
+ }
51
+
52
+ private func resolveStorage(key: String, required _: Bool) -> String? {
53
+ let data = key.split(separator: ".", maxSplits: 1)
54
+ let (namespace, storageKey): (String?, String) = {
55
+ switch data.count {
56
+ case 2:
57
+ let namespace = String(data.first ?? "")
58
+ let storageKey = String(data.last ?? "")
59
+ return (namespace.isEmpty ? nil : namespace, storageKey)
60
+ default:
61
+ return (nil, key)
62
+ }
63
+ }()
64
+
65
+ if let value = FacadeConnector.connector?.storage?.sessionStorageValue(for: storageKey,
66
+ namespace: namespace) {
67
+ return value
68
+ }
69
+
70
+ if let value = FacadeConnector.connector?.storage?.localStorageValue(for: storageKey,
71
+ namespace: namespace) {
72
+ return value
73
+ }
74
+
75
+ return nil
76
+ }
77
+ }
@@ -30,7 +30,7 @@ RCT_EXTERN_METHOD(setAppLanguage:(NSString *)language
30
30
 
31
31
  @interface RCT_EXTERN_MODULE (AnalyticsBridge, NSObject)
32
32
  RCT_EXTERN_METHOD(postEvent:(NSString *)event
33
- payload:(NSDictionary *)payload);
33
+ payload:(NSDictionary *)payload);
34
34
  RCT_EXTERN_METHOD(postTimedEvent:(NSString *)event
35
35
  payload:(NSDictionary *)payload);
36
36
  RCT_EXTERN_METHOD(endTimedEvent:(NSString *)event
@@ -151,18 +151,24 @@ RCT_EXTERN_METHOD(switchLayout:(NSString *)layoutId
151
151
  rejecter:(RCTPromiseRejectBlock)reject);
152
152
 
153
153
  RCT_EXTERN_METHOD(getCurrentLayoutId:(RCTPromiseResolveBlock)resolve
154
- rejecter:(RCTPromiseRejectBlock)reject);
154
+ rejecter:(RCTPromiseRejectBlock)reject);
155
155
 
156
156
  RCT_EXTERN_METHOD(getDefaultLayoutId:(RCTPromiseResolveBlock)resolve
157
- rejecter:(RCTPromiseRejectBlock)reject);
157
+ rejecter:(RCTPromiseRejectBlock)reject);
158
158
  @end
159
159
 
160
- @interface RCT_EXTERN_MODULE(AirPlayButtonModule, RCTViewManager)
160
+ @interface RCT_EXTERN_MODULE (AirPlayButtonModule, RCTViewManager)
161
161
  RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor);
162
162
  RCT_EXPORT_VIEW_PROPERTY(activeTintColor, UIColor);
163
-
163
+
164
164
  @end
165
165
 
166
166
  @interface RCT_EXTERN_MODULE (ReactNativeEventBusBridge, NSObject)
167
167
  RCT_EXTERN_METHOD(postEvent:(NSDictionary *)event);
168
168
  @end
169
+
170
+ @interface RCT_EXTERN_MODULE (ContextResolverBridge, NSObject)
171
+ RCT_EXTERN_METHOD(resolveContextKeys:(NSDictionary *)keys
172
+ resolver:(RCTPromiseResolveBlock)resolver
173
+ rejecter:(RCTPromiseRejectBlock)rejecter);
174
+ @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-native-apple",
3
- "version": "6.16.1",
3
+ "version": "6.18.0",
4
4
  "description": "iOS and tvOS native code for QuickBrick applications. This package is used to provide native logic for QuickBrick",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1"