Skip to content

Conversation

@JinUng41
Copy link
Collaborator

@JinUng41 JinUng41 commented May 25, 2025

👻 PULL REQUEST

📄 작업 내용

  • UIKit 기반 객체에서 Publisher를 쉽게 만들어 내기 위핸 퍼블리셔를 구현하였습니다.
  • RxSwift의 withLatestFrom을 비슷하게 Combine의 오퍼레이터로 구현하였습니다.
  • 구현한 코드를 바탕으로 뷰잇 생성 화면에 부분 적용하였습니다.

💻 주요 코드 설명

withLatestFrom 비슷하게 구현하기

  • Rx에서 제공하는 withLatestFrom과 유사하게, self 퍼블리셔가 값을 방출할 때를 트리거로, 지정된 다른 퍼블리셔의 최신 값을 함께 결합해 전달합니다.

1. 기본 형태

self.withLatestFrom(other) -> AnyPublisher<Other.Output, Failure>
  • self가 값을 방출할 때, other의 가장 최근 값을 함께 전달합니다.
  • other가 한 번도 값을 방출하지 않았다면 출력은 발생하지 않습니다.

2. 클로저 형태

self.withLatestFrom(other) { selfValue, otherValue in ... } -> AnyPublisher<Result, Failure>
  • self와 other의 값을 기반으로 원하는 형태로 가공할 수 있는 클로저를 받습니다.

3. 두 개의 참조 퍼블리셔와 함께 사용하는 경우

self.withLatestFrom(other1, other2) -> AnyPublisher<(Other1.Output, Other2.Output), Failure>

self.withLatestFrom(other1, other2) { selfValue, o1, o2 in ... } -> AnyPublisher<Result, Failure>
  • 두 퍼블리셔 모두 최소 한 번 이상 값을 방출해야 결과가 출력됩니다.
  • 트리거인 self가 값을 방출할 때 두 other의 최신 값을 함께 사용합니다.

왜 Date.now.timeIntervalSince1970을 사용하는가?

Combine의 combineLatest는 두 퍼블리셔 중 어느 하나라도 새로운 값을 방출하면 호출됩니다.
하지만 self의 이벤트만을 트리거로 사용하기 위해, 다음 방식으로 해결합니다:

  • self의 방출 시점마다 Date.now.timeIntervalSince1970로 타임스탬프를 부여합니다.
  • 이후 .removeDuplicates { $0.0 == $1.0 }를 통해 같은 타임스탬프로 인한 중복 호출을 방지합니다.
  • 이는 UUID를 사용하는 것보다 성능 비용이 낮습니다.

📚 참고자료

스택오버플로우

🔗 연결된 이슈

Summary by CodeRabbit

  • New Features

    • Added native Combine publishers for UIControl events, UIControl property changes, and gesture recognizers, enabling more reactive and declarative UI event handling.
    • Introduced new Combine extensions for handling button taps and text changes in input views.
    • Added withLatestFrom operators to Combine publishers for enhanced reactive stream composition.
  • Refactor

    • Replaced third-party CombineCocoa dependency with in-house Combine publishers for UIKit controls and gestures.
    • Simplified event handling in several view controllers by removing manual relay properties and using direct reactive streams from UI components.
    • Updated button tap event handling to use native Combine publishers instead of third-party extensions.
  • Bug Fixes

    • Button tap and text change events are now handled natively, improving reliability and consistency of UI event processing.
  • Chores

    • Removed the CombineCocoa package from the project dependencies.

@JinUng41 JinUng41 requested a review from youz2me May 25, 2025 07:52
@JinUng41 JinUng41 self-assigned this May 25, 2025
@JinUng41 JinUng41 added ♻️ refactor 기존 코드를 리팩토링하거나 수정하는 등 사용 (생산적인 경우) 🍻 진웅 술 한잔 가온나~ labels May 25, 2025
@coderabbitai
Copy link

coderabbitai bot commented May 25, 2025

Caution

Review failed

The pull request is closed.

"""

Walkthrough

The changes remove the CombineCocoa dependency and introduce custom Combine publishers for UIKit controls and gestures. New files implement publishers for UIControl events, properties, and gestures. The withLatestFrom operator is added for Combine publishers. Event handling in several view controllers is refactored to use these new publishers instead of CombineCocoa or manual relays.

Changes

File(s) Change Summary
Wable-iOS.xcodeproj/project.pbxproj,
Wable-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
Remove CombineCocoa package/dependency from project and workspace. Add new Combine+UIKit source files to project.
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/CombineCompatible.swift
Publishers+UIControlEvent.swift
Publishers+UIControlProperty.swift
Publishers+UIGesture.swift
Add custom Combine publishers for UIControl events, UIControl property changes, and UIGestureRecognizer events. Add CombineCompatible protocol.
Wable-iOS/Core/Combine+/Publisher+.swift Add withLatestFrom operator extensions for Combine publishers.
Wable-iOS/Presentation/Login/LoginViewController.swift
Wable-iOS/Presentation/Overview/Rank/View/RankListViewController.swift
Refactor button tap event handling to use new UIControl event publisher instead of CombineCocoa's tapPublisher.
Wable-iOS/Presentation/Viewit/Create/View/CreateViewitViewController.swift Remove PassthroughRelay properties and manual event forwarding. Refactor to use new Combine publishers from UI controls and gestures.
Wable-iOS/Presentation/Viewit/Create/View/Subview/ViewitInputView.swift Add Combine import to support Combine publishers.
Wable-iOS/Presentation/Viewit/Create/ViewModel/CreateViewitViewModel.swift Remove debounce on next/upload event streams; events are now processed immediately.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UIControl
    participant CombinePublisher
    participant ViewController
    participant ViewModel

    User->>UIControl: Interact (e.g., tap, edit)
    UIControl->>CombinePublisher: Emits event/property change
    CombinePublisher->>ViewController: Publishes event
    ViewController->>ViewModel: Binds published event as input
    ViewModel-->>ViewController: Processes input, emits output
Loading
sequenceDiagram
    participant PublisherA
    participant PublisherB
    participant withLatestFrom
    participant Subscriber

    PublisherA->>withLatestFrom: Emits value
    withLatestFrom->>PublisherB: Get latest value
    withLatestFrom->>Subscriber: Emit (PublisherA, PublisherB.latest)
Loading

Assessment against linked issues

Objective Addressed Explanation
Implement Combine+UIKit publishers (UIControlEvent, UIControlProperty, UIGesture) [#199]
Implement withLatestFrom for Combine [#199]
Remove CombineCocoa dependency [#199]
Apply new publishers to specific code areas [#199]

Suggested labels

✨ feat

Poem

A rabbit hops with nimble feet,
Out with old CombineCocoa—what a treat!
UIKit and Combine now dance as one,
Publishers for gestures, events—so much fun!
withLatestFrom joins the show,
Reactive streams in a seamless flow.
🐇✨
"""


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e7aee6a and 17aeda7.

📒 Files selected for processing (2)
  • Wable-iOS/Presentation/Viewit/Create/View/CreateViewitViewController.swift (1 hunks)
  • Wable-iOS/Presentation/Viewit/Create/View/Subview/ViewitInputView.swift (1 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
Wable-iOS/Presentation/Viewit/Create/View/Subview/ViewitInputView.swift (1)

95-100: Good button tap publisher with debouncing.

The publisher correctly handles button taps with debouncing to prevent rapid successive taps. The 300ms debounce timing might be slightly conservative (typically 200-250ms), but this could be intentional for your use case.

Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (1)

160-163: Enhancement: Consider gesture recognizer configuration options.

The current implementation creates new gesture recognizers each time. Consider adding support for:

  • Configuring gesture recognizer properties (number of taps, minimum duration, etc.)
  • Reusing gesture recognizers when possible
  • Providing a builder pattern for complex gesture configurations

Example enhancement:

func gesture<T: UIGestureRecognizer>(
    _ gestureType: Publishers.GestureType = .tap(), 
    configure: ((T) -> Void)? = nil
) -> Publishers.GesturePublisher {
    self.isUserInteractionEnabled = true
    if let config = configure, case let gesture = gestureType.gesture as? T {
        config(gesture)
    }
    return Publishers.GesturePublisher(view: self, gestureType: gestureType)
}
Wable-iOS/Core/Combine+/Publisher+.swift (1)

63-197: Consider creating comprehensive unit tests for these operators.

These operators are complex and handle edge cases that need thorough testing, especially:

  • Behavior when referenced publishers haven't emitted yet
  • Rapid emission scenarios
  • Memory management with weak references
  • Different failure types and error propagation

Would you like me to generate comprehensive unit tests for these withLatestFrom operators?

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 632c315 and edb25a4.

📒 Files selected for processing (12)
  • Wable-iOS.xcodeproj/project.pbxproj (8 hunks)
  • Wable-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (1 hunks)
  • Wable-iOS/Core/Combine+/Publisher+.swift (1 hunks)
  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/CombineCompatible.swift (1 hunks)
  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift (1 hunks)
  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (1 hunks)
  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (1 hunks)
  • Wable-iOS/Presentation/Login/LoginViewController.swift (1 hunks)
  • Wable-iOS/Presentation/Overview/Rank/View/RankListViewController.swift (1 hunks)
  • Wable-iOS/Presentation/Viewit/Create/View/CreateViewitViewController.swift (1 hunks)
  • Wable-iOS/Presentation/Viewit/Create/View/Subview/ViewitInputView.swift (2 hunks)
  • Wable-iOS/Presentation/Viewit/Create/ViewModel/CreateViewitViewModel.swift (0 hunks)
💤 Files with no reviewable changes (1)
  • Wable-iOS/Presentation/Viewit/Create/ViewModel/CreateViewitViewModel.swift
🧰 Additional context used
🧬 Code Graph Analysis (4)
Wable-iOS/Presentation/Login/LoginViewController.swift (1)
Wable-iOS/Core/Logger/WableLogger.swift (1)
  • log (14-25)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (2)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift (5)
  • receive (48-51)
  • eventHandler (89-91)
  • request (81-81)
  • cancel (84-86)
  • publisher (111-113)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (3)
  • receive (81-88)
  • request (125-125)
  • cancel (128-133)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift (2)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (5)
  • receive (54-62)
  • eventHandler (99-102)
  • request (91-91)
  • cancel (94-96)
  • publisher (126-131)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (3)
  • receive (81-88)
  • request (125-125)
  • cancel (128-133)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (2)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift (3)
  • receive (48-51)
  • request (81-81)
  • cancel (84-86)
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (3)
  • receive (54-62)
  • request (91-91)
  • cancel (94-96)
🔇 Additional comments (17)
Wable-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (1)

2-2:

Details

✅ Verification successful

LGTM: Package dependency correctly updated

The originHash update correctly reflects the removal of the CombineCocoa package dependency.

Please verify that all CombineCocoa usage has been successfully migrated to the new custom Combine+UIKit implementations:


🏁 Script executed:

#!/bin/bash
# Description: Verify complete migration from CombineCocoa to custom publishers
# Expected: No remaining CombineCocoa imports or usage

echo "=== Checking for remaining CombineCocoa imports ==="
rg -i "import.*combinecocoa" --type swift

echo "=== Checking for remaining CombineCocoa usage patterns ==="
rg "\.tapPublisher|\.textPublisher|\.isEnabledPublisher" --type swift -A 2

echo "=== Verifying custom publisher usage ==="
rg "\.publisher\(for:" --type swift -A 2

Length of output: 5050


LGTM: CombineCocoa removal validated and custom Combine+UIKit publishers in use

  • No remaining import CombineCocoa or CombineCocoa-specific APIs detected.
  • Confirmed .publisher(for:) usage across UI controls in:
    • RankListViewController
    • ProfileSegmentedHeaderView
    • ViewitInputView
    • LoginViewController
  • Custom Combine+UIKit extension files (Publishers+UIControlEvent, Publishers+UIControlProperty) are present and in use.

All CombineCocoa references have been replaced as intended.

Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/CombineCompatible.swift (1)

10-12: LGTM: Clean marker protocol implementation

The empty CombineCompatible protocol serves as an effective marker for enabling custom Combine extensions on UIKit controls. This follows the established pattern used by libraries like RxSwift's reactive extensions.

Wable-iOS/Presentation/Overview/Rank/View/RankListViewController.swift (1)

116-116:

Details

❓ Verification inconclusive

Migration to custom Combine publisher looks correct

The replacement of .tapPublisher with .publisher(for: .touchUpInside) maintains equivalent functionality for button tap events.

Please verify that the custom publisher(for:) implementation handles edge cases consistently with CombineCocoa's tapPublisher:


🏁 Script executed:

#!/bin/bash
# Description: Check the custom UIControlEventPublisher implementation
# Expected: Proper event handling and memory management

echo "=== Checking custom publisher implementation ==="
ast-grep --pattern 'extension UIControl {
  $$$
  func publisher(for $EVENT) -> $RETURN_TYPE {
    $$$
  }
  $$$
}'

echo "=== Checking for proper event handling ==="
rg "UIControlEventPublisher|UIControlEvent" --type swift -A 5

Length of output: 4502


Migration to custom Combine publisher looks correct

The switch from .tapPublisher to .publisher(for: .touchUpInside) is functionally equivalent, and the custom implementation lives in
Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift.

Please verify the following to ensure full behavioral parity with CombineCocoa’s tapPublisher:

  • UIControlEventSubscription calls control.addTarget(_:action:for:) when the subscription starts and control.removeTarget(_:action:for:) in cancel() (or deinit) to avoid leaks.
  • The subscription holds only a weak reference to control to prevent retention cycles.
  • Events are emitted strictly while the subscription is active and stop immediately upon cancellation.
Wable-iOS/Presentation/Login/LoginViewController.swift (1)

138-138: Consistent migration pattern applied correctly

Both login buttons have been successfully migrated from .tapPublisher to .publisher(for: .touchUpInside), maintaining the existing event handling and logging functionality.

Also applies to: 144-144

Wable-iOS/Presentation/Viewit/Create/View/Subview/ViewitInputView.swift (4)

8-8: LGTM! Combine import added for reactive publishers.

The Combine import is correctly added to support the new reactive publishers in the extension.


102-110: Consistent and well-implemented text field publisher.

The publisher follows the same pattern as urlStringChanged with appropriate background color changes for the description field context.


112-117:

Details

✅ Verification successful

Consistent button tap publisher implementation.

The upload button publisher follows the same pattern as nextTapped with appropriate debouncing.

Verify that the custom .publisher(for:) extension is properly implemented for UIButton:


🏁 Script executed:

#!/bin/bash
# Description: Verify the custom UIButton publisher extension implementation
# Expected: Find the implementation of publisher(for:) for UIButton/UIControl

rg -A 10 "func publisher.*UIControl\.Event" --type swift

Length of output: 578


✅ Button Tap Publisher Implementation Verified

The uploadTapped publisher correctly mirrors the nextTapped pattern with a 300 ms debounce. The publisher(for:) extension for UIControl.Event is defined in:

  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift

No further changes required.


85-93:

Details

✅ Verification successful

Well-implemented reactive text field publisher.

The publisher correctly handles text changes with proper memory management using weak self. The background color side effect provides good visual feedback.

Verify that the custom .publisher(for:keyPath:) extension is properly implemented:


🏁 Script executed:

#!/bin/bash
# Description: Verify the custom UITextField publisher extension implementation
# Expected: Find the implementation of publisher(for:keyPath:) for UITextField

rg -A 10 "func publisher.*keyPath" --type swift

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for any custom publisher(for:keyPath:) implementations in Swift
rg -A5 "extension UITextField" --type swift
rg -A5 "extension UIControl" --type swift
rg -n "publisher(" --type swift

Length of output: 814


🏁 Script executed:

#!/bin/bash
# Search for the custom publisher(for:keyPath:) extension across Swift files
rg -n "publisher(for" -A5 --type swift

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Properly search for any publisher( occurrences, escaping regex chars
rg -n "publisher\\(" -A5 --type swift

echo "---- Inspect CombineCompatible.swift ----"
# Display the first 200 lines of the CombineCompatible.swift to locate publisher extensions
sed -n '1,200p' Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/CombineCompatible.swift

Length of output: 9455


Custom publisher extension confirmed

The publisher(for: keyPath:) API is implemented in Publishers+UIControlProperty.swift, so no further action is needed:

  • Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (lines ~126): defines
    func publisher<Value>(for events: UIControl.Event, keyPath: KeyPath<Control, Value?>) -> UIControlPropertyPublisher<Control, Value?>

Everything looks correct—go ahead and merge.

Wable-iOS.xcodeproj/project.pbxproj (4)

280-283: LGTM! Clean dependency replacement with proper build configuration.

The addition of the four new Combine+UIKit files with proper PBXBuildFile entries ensures they'll be correctly compiled into the project. This architectural change from CombineCocoa to a custom implementation provides better control over the codebase and reduces external dependencies.


620-623: Proper file reference management.

The file references are correctly added with appropriate naming conventions and source tree configuration. The consistent naming pattern (Publishers+UIControlEvent.swift, Publishers+UIControlProperty.swift, etc.) makes the API surface clear and discoverable.


1951-1961: Excellent code organization with dedicated group structure.

Creating a dedicated "Combine+UIKit" group under the Extension hierarchy provides clear separation of concerns and makes the custom UIKit integration code easily discoverable. This organization will help with long-term maintainability as the codebase grows.

Also applies to: 1978-1978


2591-2591: All new files properly integrated into the build system.

The source files are correctly added to the Sources build phase, ensuring they'll be compiled and available throughout the application. The integration looks complete and follows Xcode project best practices.

Also applies to: 2625-2625, 2765-2765, 2789-2789

Wable-iOS/Presentation/Viewit/Create/View/CreateViewitViewController.swift (1)

130-134: LGTM! Clean refactor to reactive event handling.

The refactor from manual event relays to reactive Combine publishers significantly improves the code by:

  • Eliminating intermediate relay properties and manual event forwarding
  • Creating a more declarative event handling approach
  • Reducing boilerplate code

The implementation correctly leverages the new custom publishers for UIKit components.

Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlEvent.swift (1)

31-52: LGTM! Well-designed publisher with excellent documentation.

The UIControlEventPublisher implementation follows Combine best practices with:

  • Clear generic constraints and type safety
  • Comprehensive documentation with usage examples
  • Proper publisher protocol implementation

The design correctly separates concerns between the publisher and subscription objects.

Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIControlProperty.swift (1)

35-63: LGTM! Excellent property publisher design with type safety.

The UIControlPropertyPublisher implementation provides:

  • Type-safe property access via KeyPath
  • Clean separation of concerns
  • Comprehensive documentation with practical examples
  • Proper generic constraints ensuring type safety

The KeyPath-based approach is particularly elegant for accessing control properties reactively.

Wable-iOS/Presentation/Helper/Extension/Combine+UIKit/Publishers+UIGesture.swift (2)

27-46: LGTM! Well-designed GestureType enum with proper encapsulation.

The GestureType enum design is excellent:

  • Encapsulates gesture recognizer instances as associated values
  • Provides default initializers for convenience
  • Clean computed property for accessing the underlying recognizer
  • Supports all major UIKit gesture types

This approach allows for easy customization of gesture recognizers while maintaining type safety.


127-133: LGTM! Proper resource cleanup implementation.

The cancel() method correctly handles cleanup by:

  • Removing the gesture recognizer from the view
  • Clearing the subscriber reference
  • Preventing memory leaks

This is the correct pattern that should be followed in the other publisher implementations.

Comment on lines +183 to +197
func withLatestFrom<Other1: Publisher, Other2: Publisher, Result>(
_ other1: Other1,
_ other2: Other2,
_ resultSelector: @escaping (Output, Other1.Output, Other2.Output) -> Result
) -> AnyPublisher<Result, Failure>
where Other1.Failure == Failure, Other2.Failure == Failure {
self
.map { ($0, Date.now.timeIntervalSince1970) }
.combineLatest(other1, other2)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { trigger, value1, value2 in
resultSelector(trigger.0, value1, value2)
}
.eraseToAnyPublisher()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Same critical issue: Replace timestamp with UUID for reliable operation.

This final variant also needs the same fix as the others.

Apply this fix:

-func withLatestFrom<Other1: Publisher, Other2: Publisher, Result>(
-    _ other1: Other1,
-    _ other2: Other2,
-    _ resultSelector: @escaping (Output, Other1.Output, Other2.Output) -> Result
-) -> AnyPublisher<Result, Failure>
-where Other1.Failure == Failure, Other2.Failure == Failure {
-    self
-        .map { ($0, Date.now.timeIntervalSince1970) }
-        .combineLatest(other1, other2)
-        .removeDuplicates { $0.0.1 == $1.0.1 }
-        .map { trigger, value1, value2 in
-            resultSelector(trigger.0, value1, value2)
-        }
-        .eraseToAnyPublisher()
-}
+func withLatestFrom<Other1: Publisher, Other2: Publisher, Result>(
+    _ other1: Other1,
+    _ other2: Other2,
+    _ resultSelector: @escaping (Output, Other1.Output, Other2.Output) -> Result
+) -> AnyPublisher<Result, Failure>
+where Other1.Failure == Failure, Other2.Failure == Failure {
+    self
+        .map { ($0, UUID()) }
+        .combineLatest(other1, other2)
+        .removeDuplicates { $0.0.1 == $1.0.1 }
+        .map { trigger, value1, value2 in
+            resultSelector(trigger.0, value1, value2)
+        }
+        .eraseToAnyPublisher()
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func withLatestFrom<Other1: Publisher, Other2: Publisher, Result>(
_ other1: Other1,
_ other2: Other2,
_ resultSelector: @escaping (Output, Other1.Output, Other2.Output) -> Result
) -> AnyPublisher<Result, Failure>
where Other1.Failure == Failure, Other2.Failure == Failure {
self
.map { ($0, Date.now.timeIntervalSince1970) }
.combineLatest(other1, other2)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { trigger, value1, value2 in
resultSelector(trigger.0, value1, value2)
}
.eraseToAnyPublisher()
}
func withLatestFrom<Other1: Publisher, Other2: Publisher, Result>(
_ other1: Other1,
_ other2: Other2,
_ resultSelector: @escaping (Output, Other1.Output, Other2.Output) -> Result
) -> AnyPublisher<Result, Failure>
where Other1.Failure == Failure, Other2.Failure == Failure {
self
.map { ($0, UUID()) }
.combineLatest(other1, other2)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { trigger, value1, value2 in
resultSelector(trigger.0, value1, value2)
}
.eraseToAnyPublisher()
}
🤖 Prompt for AI Agents
In Wable-iOS/Core/Combine+/Publisher+.swift between lines 183 and 197, the
function uses a timestamp to track changes, which can cause unreliable behavior.
Replace the timestamp (Date.now.timeIntervalSince1970) with a UUID to ensure
uniqueness and reliable operation. Update the map operator to pair the output
with a UUID instead of a timestamp, and adjust the removeDuplicates comparison
accordingly.

Comment on lines +149 to +159
func withLatestFrom<Other1: Publisher, Other2: Publisher>(
_ other1: Other1,
_ other2: Other2
) -> AnyPublisher<(Other1.Output, Other2.Output), Failure> where Other1.Failure == Failure, Other2.Failure == Failure {
self
.map { _ in Date.now.timeIntervalSince1970 }
.combineLatest(other1, other2)
.removeDuplicates { $0.0 == $1.0 }
.map { ($0.1, $0.2) }
.eraseToAnyPublisher()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Same critical issue: Replace timestamp with UUID for proper deduplication.

This variant also suffers from the timestamp precision problem.

Apply this fix:

-func withLatestFrom<Other1: Publisher, Other2: Publisher>(
-    _ other1: Other1,
-    _ other2: Other2
-) -> AnyPublisher<(Other1.Output, Other2.Output), Failure> where Other1.Failure == Failure, Other2.Failure == Failure {
-    self
-        .map { _ in Date.now.timeIntervalSince1970 }
-        .combineLatest(other1, other2)
-        .removeDuplicates { $0.0 == $1.0 }
-        .map { ($0.1, $0.2) }
-        .eraseToAnyPublisher()
-}
+func withLatestFrom<Other1: Publisher, Other2: Publisher>(
+    _ other1: Other1,
+    _ other2: Other2
+) -> AnyPublisher<(Other1.Output, Other2.Output), Failure> where Other1.Failure == Failure, Other2.Failure == Failure {
+    let trigger = self.map { _ in UUID() }
+    return trigger
+        .combineLatest(other1, other2)
+        .removeDuplicates { $0.0 == $1.0 }
+        .map { ($0.1, $0.2) }
+        .eraseToAnyPublisher()
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func withLatestFrom<Other1: Publisher, Other2: Publisher>(
_ other1: Other1,
_ other2: Other2
) -> AnyPublisher<(Other1.Output, Other2.Output), Failure> where Other1.Failure == Failure, Other2.Failure == Failure {
self
.map { _ in Date.now.timeIntervalSince1970 }
.combineLatest(other1, other2)
.removeDuplicates { $0.0 == $1.0 }
.map { ($0.1, $0.2) }
.eraseToAnyPublisher()
}
func withLatestFrom<Other1: Publisher, Other2: Publisher>(
_ other1: Other1,
_ other2: Other2
) -> AnyPublisher<(Other1.Output, Other2.Output), Failure> where Other1.Failure == Failure, Other2.Failure == Failure {
let trigger = self.map { _ in UUID() }
return trigger
.combineLatest(other1, other2)
.removeDuplicates { $0.0 == $1.0 }
.map { ($0.1, $0.2) }
.eraseToAnyPublisher()
}
🤖 Prompt for AI Agents
In Wable-iOS/Core/Combine+/Publisher+.swift between lines 149 and 159, the
withLatestFrom function uses a timestamp for deduplication which can fail due to
precision issues. Replace the timestamp mapping with a UUID to ensure unique
values for proper deduplication. Update the map operator to emit a new UUID
instead of the current timestamp before combineLatest.

Comment on lines +86 to +95
func withLatestFrom<Other: Publisher>(
_ other: Other
) -> AnyPublisher<Other.Output, Failure> where Other.Failure == Failure {
self
.map { _ in Date.now.timeIntervalSince1970 }
.combineLatest(other)
.removeDuplicates(by: { $0.0 == $1.0 })
.map { $0.1 }
.eraseToAnyPublisher()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical issue: Timestamp-based deduplication may fail with rapid emissions.

The timestamp approach using Date.now.timeIntervalSince1970 has a fundamental flaw: if multiple emissions occur within the same timestamp resolution (which can happen with rapid UI events or high-frequency publishers), they will be incorrectly deduplicated, leading to lost events.

Consider using a more robust approach:

-func withLatestFrom<Other: Publisher>(
-    _ other: Other
-) -> AnyPublisher<Other.Output, Failure> where Other.Failure == Failure {
-    self
-        .map { _ in Date.now.timeIntervalSince1970 }
-        .combineLatest(other)
-        .removeDuplicates(by: { $0.0 == $1.0 })
-        .map { $0.1 }
-        .eraseToAnyPublisher()
-}
+func withLatestFrom<Other: Publisher>(
+    _ other: Other
+) -> AnyPublisher<Other.Output, Failure> where Other.Failure == Failure {
+    let trigger = self.map { _ in UUID() }
+    return trigger
+        .combineLatest(other)
+        .removeDuplicates(by: { $0.0 == $1.0 })
+        .map { $0.1 }
+        .eraseToAnyPublisher()
+}

Alternatively, consider implementing a custom publisher that properly tracks emission sources.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func withLatestFrom<Other: Publisher>(
_ other: Other
) -> AnyPublisher<Other.Output, Failure> where Other.Failure == Failure {
self
.map { _ in Date.now.timeIntervalSince1970 }
.combineLatest(other)
.removeDuplicates(by: { $0.0 == $1.0 })
.map { $0.1 }
.eraseToAnyPublisher()
}
func withLatestFrom<Other: Publisher>(
_ other: Other
) -> AnyPublisher<Other.Output, Failure> where Other.Failure == Failure {
let trigger = self.map { _ in UUID() }
return trigger
.combineLatest(other)
.removeDuplicates(by: { $0.0 == $1.0 })
.map { $0.1 }
.eraseToAnyPublisher()
}
🤖 Prompt for AI Agents
In Wable-iOS/Core/Combine+/Publisher+.swift around lines 86 to 95, the current
withLatestFrom implementation uses timestamps for deduplication, which can fail
when multiple events occur within the same timestamp resolution, causing lost
events. To fix this, replace the timestamp-based deduplication with a method
that tracks emissions more reliably, such as using a sequence number or a custom
publisher that explicitly tracks and combines the latest values from both
publishers without relying on timestamps.

Comment on lines +118 to +128
func withLatestFrom<Other: Publisher, Result>(
_ other: Other,
_ resultSelector: @escaping (Output, Other.Output) -> Result
) -> AnyPublisher<Result, Failure> where Other.Failure == Failure {
self
.map { ($0, Date.now.timeIntervalSince1970) }
.combineLatest(other)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { lhs, rhs in resultSelector(lhs.0, rhs) }
.eraseToAnyPublisher()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Same critical issue: Use UUID instead of timestamp for reliable deduplication.

This variant has the same timestamp precision issue as the first variant.

Apply this fix:

-func withLatestFrom<Other: Publisher, Result>(
-    _ other: Other,
-    _ resultSelector: @escaping (Output, Other.Output) -> Result
-) -> AnyPublisher<Result, Failure> where Other.Failure == Failure {
-    self
-        .map { ($0, Date.now.timeIntervalSince1970) }
-        .combineLatest(other)
-        .removeDuplicates { $0.0.1 == $1.0.1 }
-        .map { lhs, rhs in resultSelector(lhs.0, rhs) }
-        .eraseToAnyPublisher()
-}
+func withLatestFrom<Other: Publisher, Result>(
+    _ other: Other,
+    _ resultSelector: @escaping (Output, Other.Output) -> Result
+) -> AnyPublisher<Result, Failure> where Other.Failure == Failure {
+    self
+        .map { ($0, UUID()) }
+        .combineLatest(other)
+        .removeDuplicates { $0.0.1 == $1.0.1 }
+        .map { lhs, rhs in resultSelector(lhs.0, rhs) }
+        .eraseToAnyPublisher()
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func withLatestFrom<Other: Publisher, Result>(
_ other: Other,
_ resultSelector: @escaping (Output, Other.Output) -> Result
) -> AnyPublisher<Result, Failure> where Other.Failure == Failure {
self
.map { ($0, Date.now.timeIntervalSince1970) }
.combineLatest(other)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { lhs, rhs in resultSelector(lhs.0, rhs) }
.eraseToAnyPublisher()
}
func withLatestFrom<Other: Publisher, Result>(
_ other: Other,
_ resultSelector: @escaping (Output, Other.Output) -> Result
) -> AnyPublisher<Result, Failure> where Other.Failure == Failure {
self
.map { ($0, UUID()) }
.combineLatest(other)
.removeDuplicates { $0.0.1 == $1.0.1 }
.map { lhs, rhs in resultSelector(lhs.0, rhs) }
.eraseToAnyPublisher()
}
🤖 Prompt for AI Agents
In Wable-iOS/Core/Combine+/Publisher+.swift around lines 118 to 128, the current
implementation uses timestamps for deduplication, which can cause reliability
issues due to limited precision. Replace the timestamp with a UUID to ensure
each event is uniquely identified. Update the map operator to pair the output
with a UUID instead of the timestamp, and keep the rest of the logic unchanged
to maintain deduplication based on the UUID.

Copy link
Member

@youz2me youz2me left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!! 직접 설명 들으면서 qna한 부분도 있어서 그 부분은 따로 남기지 않았습니다 ㅎ ㅎ
추가로 궁금한 부분만 리뷰 남겨두었어요! 확인해주시면 좋을 것 같습니닷.
구현해주신 코드 잘 가져다 쓰도록 하겠습니다 ,, 굿.

Comment on lines 84 to 119
extension ViewitInputView {
var urlStringChanged: AnyPublisher<String, Never> {
urlTextField
.publisher(for: .editingChanged, keyPath: \.text)
.compactMap { $0 }
.handleEvents(receiveOutput: { [weak self] text in
self?.urlTextField.backgroundColor = text.isEmpty ? .gray100 : .blue10
})
.eraseToAnyPublisher()
}

var nextTapped: AnyPublisher<Void, Never> {
nextButton
.publisher(for: .touchUpInside)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}

var descriptionChanged: AnyPublisher<String, Never> {
descriptionTextField
.publisher(for: .editingChanged, keyPath: \.text)
.compactMap { $0 }
.handleEvents(receiveOutput: { [weak self] text in
self?.descriptionTextField.backgroundColor = text.isEmpty ? .gray100 : .wableWhite
})
.eraseToAnyPublisher()
}

var uploadTapped: AnyPublisher<Void, Never> {
uploadButton
.publisher(for: .touchUpInside)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View에서 Combine을 임포트해서 이런 액션을 정의하는 게 맞을까라는 의문이 들어요. 개인적으로는 뷰컨에 있는 게 맞는 것 같아서 의견이 궁금합니닷.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

또 듣다 보니, 그게 더 맞을지도 모르겠네요.
일단 뷰컨의 setupBinding에서 코드를 작성하여 반영해 보겠습니다.

Comment on lines 130 to 134
urlStringChanged: viewitInputView.urlStringChanged,
next: viewitInputView.nextTapped,
descriptionChanged: viewitInputView.descriptionChanged,
upload: viewitInputView.uploadTapped,
backgroundTap: dimmedBackgroundView.gesture().asVoid()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines -129 to -142
func setupAction() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundViewDidTap))
dimmedBackgroundView.addGestureRecognizer(tapGesture)

viewitInputView.urlTextField.addTarget(self, action: #selector(urlTextFieldDidChange(_:)), for: .editingChanged)
viewitInputView.nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside)
viewitInputView.descriptionTextField.addTarget(
self,
action: #selector(descriptionTextFieldDidChange(_:)),
for: .editingChanged
)
viewitInputView.uploadButton.addTarget(self, action: #selector(uploadButtonDidTap), for: .touchUpInside)
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

액션 코드 사라진 게 너무 마음이 편안하네요!!!

@github-project-automation github-project-automation bot moved this to In Review in Wable-iOS May 26, 2025
@JinUng41 JinUng41 merged commit d538eb7 into develop May 26, 2025
1 check was pending
@github-project-automation github-project-automation bot moved this from In Review to Done in Wable-iOS May 26, 2025
@JinUng41 JinUng41 deleted the refactor/#199-combine+-uikit-withlatestfrom branch May 26, 2025 15:41
youz2me pushed a commit that referenced this pull request Oct 26, 2025
…tfrom

[Refactor] Combine+UIKit, withLatestFrom 구현하기
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

♻️ refactor 기존 코드를 리팩토링하거나 수정하는 등 사용 (생산적인 경우) 🍻 진웅 술 한잔 가온나~

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Refactor] Combine+UIKit을 위한 퍼블리셔 구현 및 withLatestFrom 구현

3 participants