完善任务流程、键盘适配与页面交互

This commit is contained in:
2026-07-10 15:56:15 +08:00
parent f88a85a807
commit ab5220e460
189 changed files with 16779 additions and 1038 deletions
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,243 @@
//
// IQKeyboardInfo.swift
// https://github.com/hackiftekhar/IQKeyboardNotification
// Copyright (c) 2013-24 Iftekhar Qurashi.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import UIKit
@available(iOSApplicationExtension, unavailable)
public struct IQKeyboardInfo: Equatable {
nonisolated public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.event == rhs.event &&
lhs.endFrame.equalTo(rhs.endFrame)
}
@objc public enum Event: Int, CaseIterable {
case willShow
case didShow
case willChangeFrame
case didChangeFrame
case willHide
case didHide
@MainActor
public var notification: Notification.Name {
switch self {
case .willShow:
return UIResponder.keyboardWillShowNotification
case .didShow:
return UIResponder.keyboardDidShowNotification
case .willChangeFrame:
return UIResponder.keyboardWillChangeFrameNotification
case .didChangeFrame:
return UIResponder.keyboardDidChangeFrameNotification
case .willHide:
return UIResponder.keyboardWillHideNotification
case .didHide:
return UIResponder.keyboardDidHideNotification
}
}
}
public let event: Event
/// `keyboardIsLocalUserInfoKey`.
public let isLocal: Bool
/// `UIKeyboardFrameBeginUserInfoKey`.
public let beginFrame: CGRect
/// `UIKeyboardFrameEndUserInfoKey`.
public let endFrame: CGRect
/// `UIKeyboardAnimationDurationUserInfoKey`.
public let animationDuration: TimeInterval
/// `UIKeyboardAnimationCurveUserInfoKey`.
public let animationCurve: UIView.AnimationCurve
public var animationOptions: UIView.AnimationOptions {
return UIView.AnimationOptions(rawValue: UInt(animationCurve.rawValue << 16))
}
public var isVisible: Bool {
endFrame.height > 0
}
internal init(notification: Notification?, event: Event) {
self.event = event
let screenBounds: CGRect
if let screen: UIScreen = notification?.object as? UIScreen {
screenBounds = screen.bounds
} else {
screenBounds = UIScreen.main.bounds
}
if let info: [AnyHashable: Any] = notification?.userInfo {
if let value = info[UIResponder.keyboardIsLocalUserInfoKey] as? Bool {
isLocal = value
} else {
isLocal = true
}
// Getting keyboard animation.
if let curveValue: Int = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
let curve: UIView.AnimationCurve = UIView.AnimationCurve(rawValue: curveValue) {
animationCurve = curve
} else {
animationCurve = .easeOut
}
// Getting keyboard animation duration
if let duration: TimeInterval = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
duration != 0.0 {
animationDuration = duration
} else {
animationDuration = 0.25
}
if let beginKeyboardFrame: CGRect = info[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect {
beginFrame = Self.getKeyboardFrame(of: beginKeyboardFrame, inScreenBounds: screenBounds)
} else {
beginFrame = CGRect(x: 0, y: screenBounds.height, width: screenBounds.width, height: 0)
}
var endFrame: CGRect
if let endKeyboardFrame: CGRect = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
endFrame = Self.getKeyboardFrame(of: endKeyboardFrame, inScreenBounds: screenBounds)
} else {
endFrame = CGRect(x: 0, y: screenBounds.height, width: screenBounds.width, height: 0)
}
// With iOS 26, the endFrame returns height equal to the input accessory view height even when keyboard is dismissed.
// Unfortunately we have to assume this hack for iOS 26.
#if compiler(>=6.2) // Xcode 26
if #available(iOS 26.0, *) {
switch event {
case .willHide, .didHide:
endFrame.origin.y += endFrame.size.height
endFrame.size.height = 0
case .willShow, .didShow, .willChangeFrame, .didChangeFrame:
break
}
}
#endif
self.endFrame = endFrame
} else {
isLocal = true
animationCurve = .easeOut
animationDuration = 0.25
beginFrame = CGRect(x: 0, y: screenBounds.height, width: screenBounds.width, height: 0)
endFrame = CGRect(x: 0, y: screenBounds.height, width: screenBounds.width, height: 0)
}
}
@MainActor
public func animate(alongsideTransition transition: @escaping () -> Void, completion: (() -> Void)? = nil) {
// if let timing = UIView.AnimationCurve.RawValue(exactly: animationCurve.rawValue),
// let curve = UIView.AnimationCurve(rawValue: timing) {
// let animator = UIViewPropertyAnimator(duration: animationDuration, curve: curve) {
// transition()
// }
// animator.addCompletion { _ in
// completion?()
// }
// animator.isUserInteractionEnabled = true
// animator.startAnimation()
// } else {
var animationOptions: UIView.AnimationOptions = self.animationOptions
animationOptions.formUnion(.allowUserInteraction)
animationOptions.formUnion(.beginFromCurrentState)
UIView.animate(withDuration: animationDuration, delay: 0,
options: animationOptions,
animations: transition,
completion: { _ in
completion?()
})
// }
}
}
@available(iOSApplicationExtension, unavailable)
private extension IQKeyboardInfo {
static func getKeyboardFrame(of rect: CGRect, inScreenBounds screenBounds: CGRect) -> CGRect {
var finalFrame: CGRect = rect
// If this is floating keyboard
if finalFrame.width < screenBounds.width,
finalFrame.maxY < screenBounds.height {
finalFrame.size = CGSize(width: finalFrame.size.width, height: 0)
} else {
// (Bug ID: #469) (Bug ID: #381) (Bug ID: #1506)
// Calculating actual keyboard covered size respect to window,
// keyboard frame may be different when hardware keyboard is attached
let keyboardHeight = CGFloat.maximum(screenBounds.height - finalFrame.minY, 0)
finalFrame.size = CGSize(width: finalFrame.size.width, height: keyboardHeight)
}
return finalFrame
}
}
@available(iOSApplicationExtension, unavailable)
@objcMembers public class IQKeyboardInfoObjC: NSObject {
private let wrappedValue: IQKeyboardInfo
public var event: IQKeyboardInfo.Event { wrappedValue.event }
public var isLocal: Bool { wrappedValue.isLocal }
public var beginFrame: CGRect { wrappedValue.beginFrame }
public var endFrame: CGRect { wrappedValue.endFrame }
public var animationDuration: TimeInterval { wrappedValue.animationDuration }
public var animationCurve: UIView.AnimationCurve { wrappedValue.animationCurve }
public var animationOptions: UIView.AnimationOptions { wrappedValue.animationOptions }
public var isVisible: Bool { wrappedValue.isVisible }
init(wrappedValue: IQKeyboardInfo){
self.wrappedValue = wrappedValue
}
@MainActor
public func animate(alongsideTransition transition: @escaping () -> Void, completion: (() -> Void)? = nil) {
wrappedValue.animate(alongsideTransition: transition, completion: completion)
}
}
// MARK: Deprecated
@available(iOSApplicationExtension, unavailable)
public extension IQKeyboardInfo {
@available(*, unavailable, renamed: "event")
var name: Event { event }
@available(*, unavailable, renamed: "isVisible")
var keyboardShowing: Bool { isVisible }
}
@@ -0,0 +1,172 @@
//
// IQKeyboardNotification.swift
// https://github.com/hackiftekhar/IQKeyboardNotification
// Copyright (c) 2013-24 Iftekhar Qurashi.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import UIKit
import Combine
@available(iOSApplicationExtension, unavailable)
@MainActor
@objcMembers public final class IQKeyboardNotification: NSObject {
private var storage: Set<AnyCancellable> = []
private var eventObservers: [IQKeyboardInfo.Event: [AnyHashable: SizeCompletion]] = [:]
public private(set) var oldKeyboardInfo: IQKeyboardInfo
public private(set) var keyboardInfo: IQKeyboardInfo {
didSet {
guard keyboardInfo != oldValue else { return }
oldKeyboardInfo = oldValue
sendKeyboardInfo(info: keyboardInfo)
}
}
public var isVisible: Bool {
keyboardInfo.isVisible
}
public var frame: CGRect {
keyboardInfo.endFrame
}
public override init() {
keyboardInfo = IQKeyboardInfo(notification: nil, event: .didHide)
oldKeyboardInfo = keyboardInfo
super.init()
// Registering for keyboard notification.
for event in IQKeyboardInfo.Event.allCases {
NotificationCenter.default.publisher(for: event.notification)
.map({ IQKeyboardInfo(notification: $0, event: event) })
.sink(receiveValue: { [weak self] keyboardInfo in
self?.keyboardInfo = keyboardInfo
})
.store(in: &storage)
}
}
public func animate(alongsideTransition transition: @escaping () -> Void, completion: (() -> Void)? = nil) {
keyboardInfo.animate(alongsideTransition: transition, completion: completion)
}
}
@available(iOSApplicationExtension, unavailable)
@MainActor
public extension IQKeyboardNotification {
typealias SizeCompletion = (_ event: IQKeyboardInfo.Event, _ endFrame: CGRect) -> Void
func subscribe(for events: [IQKeyboardInfo.Event],
identifier: AnyHashable, changeHandler: @escaping SizeCompletion) {
for event in events {
var existingObservers: [AnyHashable: SizeCompletion] = eventObservers[event] ?? [:]
existingObservers[identifier] = changeHandler
eventObservers[event] = existingObservers
}
// If current event is the one user is subscribed to, then call changeHandler immediately for the first time.
if events.contains(keyboardInfo.event) {
changeHandler(keyboardInfo.event, keyboardInfo.endFrame)
}
}
func unsubscribe(for events: [IQKeyboardInfo.Event], identifier: AnyHashable) {
for event in events {
var existingObservers: [AnyHashable: SizeCompletion] = eventObservers[event] ?? [:]
existingObservers[identifier] = nil
eventObservers[event] = existingObservers
}
}
func isSubscribed(for event: IQKeyboardInfo.Event? = nil, identifier: AnyHashable) -> Bool {
if let event = event {
guard let observers = eventObservers[event], !observers.isEmpty else { return false }
return observers[identifier] != nil
} else {
for event in IQKeyboardInfo.Event.allCases {
let observers = eventObservers[event] ?? [:]
if observers[identifier] != nil {
return true
}
}
return false
}
}
private func sendKeyboardInfo(info: IQKeyboardInfo) {
guard let observers = eventObservers[info.event], !observers.isEmpty else { return }
let endFrame: CGRect = info.endFrame
for block in observers.values {
block(info.event, endFrame)
}
}
}
@available(iOSApplicationExtension, unavailable)
@MainActor
@objc public extension IQKeyboardNotification {
var oldKeyboardInfoObjc: IQKeyboardInfoObjC { IQKeyboardInfoObjC(wrappedValue: oldKeyboardInfo) }
var keyboardInfoObjc: IQKeyboardInfoObjC { IQKeyboardInfoObjC(wrappedValue: keyboardInfo) }
func subscribe(identifier: AnyHashable, changeHandler: @escaping SizeCompletion) {
subscribe(for: IQKeyboardInfo.Event.allCases, identifier: identifier, changeHandler: changeHandler)
}
func unsubscribe(identifier: AnyHashable) {
unsubscribe(for: IQKeyboardInfo.Event.allCases, identifier: identifier)
}
func isSubscribed(identifier: AnyHashable) -> Bool {
isSubscribed(for: nil, identifier: identifier)
}
}
// MARK: Deprecated
@available(iOSApplicationExtension, unavailable)
@objc public extension IQKeyboardNotification {
// MARK: Deprecated
@available(*, unavailable, renamed: "isVisible")
var keyboardShowing: Bool { isVisible }
@available(*, unavailable, renamed: "subscribe(identifier:changeHandler:)")
func registerSizeChange(identifier: AnyHashable, changeHandler: @escaping SizeCompletion) {
subscribe(identifier: identifier, changeHandler: changeHandler)
}
@available(*, unavailable, renamed: "unsubscribe(identifier:)")
func unregisterSizeChange(identifier: AnyHashable) {
unsubscribe(identifier: identifier)
}
}