From 2614abd57ca7eeab7820260b7bca2c583e19f778 Mon Sep 17 00:00:00 2001 From: Inhwan Kim Date: Thu, 27 Nov 2025 14:29:56 +0900 Subject: [PATCH 1/2] Add multi-ScrollView detection and best match selection on DynamicOverlayScrollViewProxy. --- .../Handle/ActivatedOverlayArea.swift | 9 ++++ .../DynamicOverlayScrollViewProxy.swift | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Source/Internal/Handle/ActivatedOverlayArea.swift b/Source/Internal/Handle/ActivatedOverlayArea.swift index 2e11515..96f932b 100644 --- a/Source/Internal/Handle/ActivatedOverlayArea.swift +++ b/Source/Internal/Handle/ActivatedOverlayArea.swift @@ -40,6 +40,15 @@ struct ActivatedOverlayArea: Equatable { && $0.frame != .zero } } + + func intersectionArea(with rect: CGRect) -> CGFloat { + spots.reduce(0) { totalArea, spot in + guard spot.frame != .zero else { return totalArea } + let intersection = rect.intersection(spot.frame) + let area = intersection.width * intersection.height + return totalArea + (area > 0 ? area : 0) + } + } } extension ActivatedOverlayArea { diff --git a/Source/Internal/Handle/DrivingScrollView/DynamicOverlayScrollViewProxy.swift b/Source/Internal/Handle/DrivingScrollView/DynamicOverlayScrollViewProxy.swift index 209919c..4d1d983 100644 --- a/Source/Internal/Handle/DrivingScrollView/DynamicOverlayScrollViewProxy.swift +++ b/Source/Internal/Handle/DrivingScrollView/DynamicOverlayScrollViewProxy.swift @@ -21,7 +21,27 @@ struct DynamicOverlayScrollViewProxy: Equatable { } func findScrollView(in space: UIView) -> UIScrollView? { - space.findScrollView(in: area, coordinate: space) + let allScrollViews = space.findAllScrollViews(in: area, coordinate: space) + guard !allScrollViews.isEmpty else { return nil } + + // If only one ScrollView found, return it directly + if allScrollViews.count == 1 { + return allScrollViews.first + } + + // Select the ScrollView with maximum intersection area with the overlay area + // When areas are equal, prefer the last one in the array + return allScrollViews.max { scrollView1, scrollView2 in + let frame1 = space.convert(scrollView1.bounds, from: scrollView1) + let frame2 = space.convert(scrollView2.bounds, from: scrollView2) + let area1 = area.intersectionArea(with: frame1) + let area2 = area.intersectionArea(with: frame2) + return area1 <= area2 + } + } + + func findAllScrollViews(in space: UIView) -> [UIScrollView] { + space.findAllScrollViews(in: area, coordinate: space) } } @@ -53,4 +73,23 @@ private extension UIView { } return nil } + + func findAllScrollViews(in area: ActivatedOverlayArea, + coordinate: UICoordinateSpace) -> [UIScrollView] { + let frame = coordinate.convert(bounds, from: self) + guard area.intersects(frame) else { return [] } + + var scrollViews: [UIScrollView] = [] + + if let scrollView = self as? UIScrollView { + scrollViews.append(scrollView) + } + + for subview in subviews { + let foundScrollViews = subview.findAllScrollViews(in: area, coordinate: coordinate) + scrollViews.append(contentsOf: foundScrollViews) + } + + return scrollViews + } } From e1d2f3b2ca33b3db74f76c246d9833b1c9acf442 Mon Sep 17 00:00:00 2001 From: Inhwan Kim Date: Thu, 27 Nov 2025 15:35:51 +0900 Subject: [PATCH 2/2] Add test cases for multi-ScrollView selection and fix window property on test utils. --- .../DrivingScrollViewModifierTests.swift | 100 ++++++++++++++++++ .../Utils/ViewRenderer.swift | 37 ++++++- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/Tests/DynamicOverlayTests/DrivingScrollViewModifierTests.swift b/Tests/DynamicOverlayTests/DrivingScrollViewModifierTests.swift index cddfbd3..0b5210e 100644 --- a/Tests/DynamicOverlayTests/DrivingScrollViewModifierTests.swift +++ b/Tests/DynamicOverlayTests/DrivingScrollViewModifierTests.swift @@ -93,4 +93,104 @@ class DrivingScrollViewModifierTests: XCTestCase { ViewRenderer(view: view).render() wait(for: [expectation], timeout: 0.1) } + + func testMultipleScrollViewsSelection() { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + + // Create multiple ScrollViews with different sizes and positions + let scrollView1 = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + scrollView1.id = "small" + + let scrollView2 = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + scrollView2.id = "medium" + + let scrollView3 = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 300, height: 300)) + scrollView3.id = "large" + + container.addSubview(scrollView1) + container.addSubview(scrollView2) + container.addSubview(scrollView3) + + // Create an overlay area that intersects all ScrollViews + let overlayArea = ActivatedOverlayArea.active(CGRect(x: 0, y: 0, width: 150, height: 150)) + let proxy = DynamicOverlayScrollViewProxy(area: overlayArea) + + // Should select the ScrollView with maximum intersection area + let selectedScrollView = proxy.findScrollView(in: container) as? IdentifiedScrollView + XCTAssertNotNil(selectedScrollView) + // The 150x150 overlay intersects: + // - scrollView1 (100x100): 100*100 = 10,000 area + // - scrollView2 (200x200): 150*150 = 22,500 area (maximum) + // - scrollView3 (300x300): 150*150 = 22,500 area (maximum, but added later) + // Should select scrollView3 as it's the last one with max area + XCTAssertEqual(selectedScrollView?.id, "large") + } + + func testMultipleScrollViewsWithPartialOverlap() { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + + // ScrollView in top-left quadrant + let scrollView1 = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + scrollView1.id = "top-left" + + // ScrollView in bottom-right quadrant + let scrollView2 = IdentifiedScrollView(frame: CGRect(x: 200, y: 200, width: 200, height: 200)) + scrollView2.id = "bottom-right" + + container.addSubview(scrollView1) + container.addSubview(scrollView2) + + // Overlay area in top-left quadrant + let overlayArea = ActivatedOverlayArea.active(CGRect(x: 50, y: 50, width: 100, height: 100)) + let proxy = DynamicOverlayScrollViewProxy(area: overlayArea) + + let selectedScrollView = proxy.findScrollView(in: container) as? IdentifiedScrollView + XCTAssertNotNil(selectedScrollView) + XCTAssertEqual(selectedScrollView?.id, "top-left") + } + + func testFindAllScrollViews() { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + + let scrollView1 = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + scrollView1.id = "first" + + let scrollView2 = IdentifiedScrollView(frame: CGRect(x: 200, y: 0, width: 200, height: 200)) + scrollView2.id = "second" + + let scrollView3 = IdentifiedScrollView(frame: CGRect(x: 0, y: 200, width: 200, height: 200)) + scrollView3.id = "third" + + container.addSubview(scrollView1) + container.addSubview(scrollView2) + container.addSubview(scrollView3) + + // Overlay area that intersects all three + let overlayArea = ActivatedOverlayArea.active(CGRect(x: 0, y: 0, width: 400, height: 400)) + let proxy = DynamicOverlayScrollViewProxy(area: overlayArea) + + let allScrollViews = proxy.findAllScrollViews(in: container) + XCTAssertEqual(allScrollViews.count, 3) + + let ids = (allScrollViews as? [IdentifiedScrollView])?.map { $0.id }.sorted() + XCTAssertEqual(ids, ["first", "second", "third"]) + } + + func testNoScrollViewWhenNoIntersection() { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + + let scrollView = IdentifiedScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + scrollView.id = "isolated" + container.addSubview(scrollView) + + // Overlay area that doesn't intersect the ScrollView + let overlayArea = ActivatedOverlayArea.active(CGRect(x: 300, y: 300, width: 50, height: 50)) + let proxy = DynamicOverlayScrollViewProxy(area: overlayArea) + + let selectedScrollView = proxy.findScrollView(in: container) + XCTAssertNil(selectedScrollView) + + let allScrollViews = proxy.findAllScrollViews(in: container) + XCTAssertTrue(allScrollViews.isEmpty) + } } diff --git a/Tests/DynamicOverlayTests/Utils/ViewRenderer.swift b/Tests/DynamicOverlayTests/Utils/ViewRenderer.swift index bed8200..c5a193b 100644 --- a/Tests/DynamicOverlayTests/Utils/ViewRenderer.swift +++ b/Tests/DynamicOverlayTests/Utils/ViewRenderer.swift @@ -27,8 +27,31 @@ class ViewRenderer { bounds.inset(by: safeAreaInsets) } + private var _window: UIWindow? + var window: UIWindow { - UIApplication.shared.windows.first! + if let existingWindow = _window { + return existingWindow + } + + if let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first { + _window = window + return window + } + + if let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first, + let window = windowScene.windows.first { + _window = window + return window + } + + let newWindow = UIWindow(frame: UIScreen.main.bounds) + _window = newWindow + return newWindow } private let hostController: UIHostingController @@ -38,9 +61,17 @@ class ViewRenderer { } func render() { - if window.rootViewController !== hostController { - window.rootViewController = hostController + let targetWindow = window + + if targetWindow.rootViewController !== hostController { + targetWindow.rootViewController = hostController + + // Window를 보이게 만들기 + if !targetWindow.isKeyWindow { + targetWindow.makeKeyAndVisible() + } } + CATransaction.flush() } }