Skip to content

2.0.0 — Swift 6 / iOS 15

Choose a tag to compare

@haoking haoking released this 28 Jul 15:04

The first Swift 6 language mode release. Minimum deployment target is now iOS 15.0.

This is a breaking upgrade from 1.8.0 — read the Breaking Changes table below and the Migration from 1.x section in the README before upgrading.

22 bugs fixed, several crash-level. All deprecated APIs removed. All objc_getAssociatedObject pseudo-storage replaced with real stored properties. The library went from zero tests to 113 unit tests and 6 UI tests, with GitHub Actions CI.

Three of the fixes are worth calling out because they were invisible until the whole codebase was looked at together:

  • SwiftyAlertView's keyboard avoidance has never worked in any released version. Both observers were registered for keyboardWillShowNotification, and the second one's body is the hide logic — so every keyboard appearance shifted the alert up and immediately put it back. 2.0 is the first release where an alert with addTextField() actually moves out of the keyboard's way. (B20)
  • SwiftyThreadPool could never deallocate. A perpetual RunLoop operation held a strong reference to the pool and looped on a flag that could never be set, which also made the deinit-based observer cleanup unreachable. The operation turned out to have never been enqueued in 1.x at all, and to do nothing even when it ran, so it was removed. (B9)
  • SwiftyToast was positioned entirely off-screen in landscape. The layout swapped the container's width and height, correct for iOS 7 and earlier where the window did not rotate — wrong for every release since iOS 8. (B22)

The first Swift 6 language mode release. Minimum deployment target is now iOS 15.0. This is a breaking upgrade from 1.8.0 — see Breaking Changes below and the Migration from 1.x section in the README.

Breaking Changes

Change Migration
ImageCachePool.defalut.default The compiler fix-it renames call sites automatically; the old (misspelled) name is kept as a deprecated alias for one major version
SwiftyThreadPool.defalut.default Same as above
SwiftyGlobal.keyWindow is now UIWindow? Callers must handle the optional
SwiftyGlobal.navHeight / barHeight removed Use SwiftyGlobal.safeAreaInsets instead
SwiftyLabel.textColor is now non-optional UIColor Remove any unwrapping
UIImage.colors(_:) dominant-color sort direction (fix for B12) The internal sort was ascending, so .first picked the least-frequent edge color; it's now descending and picks the most-frequent one. Background/primary/secondary/detail output for real images can change. Callers that depend on specific output colors should re-verify them
All UI types (SwiftyView, SwiftyLabel, SwiftyButton, SwiftyImageView, SwiftyToast, SwiftyAlertView, SwiftyGlobal, …) are now @MainActor-isolated Code that reads or writes their properties must run on the main thread; this is enforced at compile time, not just by convention
Promise<T> now requires T: Sendable; the closures passed to firstly/then/always/catch must be @Sendable; UIImage.colors(_:)'s callback is @MainActor @Sendable Mostly visible to Swift 6-mode callers doing strict concurrency checking. Make captured state Sendable, or adjust closures accordingly. The colors(_:) callback's "always called on the main thread" guarantee is now enforced by the type system instead of just documented
The global isOpaque optimization is off by default Call SwiftyUI.enableGlobalOpaqueOptimization() explicitly if you relied on it. It is irreversible: it swizzles UIView.backgroundColor and UIView.alpha process-wide and there is deliberately no disable counterpart — a second swizzle does not restore the originals if another library has swizzled the same pair in the meantime
TextEditable / ImageCacheable no longer ship a default, associated-object-backed implementation Custom conformers must supply their own storage (in practice, SwiftyLabel and ImageCachePool are the only conformers in this library)
TextEditable and ImageSettable are now constrained to UIView (they were AnyObject) Non-UIView conformers no longer compile. Neither protocol was ever usable off a view — every 1.x default implementation was already declared where Self: UIView
TextEditable.loadText() has been removed No replacement. Its whole body was layoutManager.addTextContainer(container), against associated-object storage that no longer exists; SwiftyLabel builds its own TextKit stack in init
TextEditable.mergedAttributedText is no longer public (it is now internal on SwiftyLabel) No replacement — it was a rendering detail of the deleted protocol extension. Read attributedText instead
SwiftyGlobal is now an enum instead of a final class Every member was already static and the type had no instance state. Remove any SwiftyGlobal() call
ImageSettable now declares a storage requirement (var backgroundImage: UIImage? { get set }) Conformers must provide a real backgroundImage stored property and push it to the layer themselves by calling the new applyBackgroundImage(_:). A stored property on its own compiles but never touches layer.contents, so nothing renders. Both conformers in this library do the second half: SwiftyImageView.swift:14 and SwiftyButton.swift:21. See the conformance example in the README
NSObject.swizzleInstance is now internal; NSObject.swizzleStatic has been removed entirely (it had zero callers in the library) No replacement — both were implementation details. Use SwiftyUI.enableGlobalOpaqueOptimization() if you need the opt-in global swizzle behavior
ImageCachePool.add(_:withIdentifier:) is now synchronous Remove any code that waited/polled for the write to land
The swizzle of UIControl.sendAction has been removed, and tap debounce now covers less than it used to 1.x swizzled sendAction(_:to:for:) on SwiftyButton, which throttled every action the button sent — any control event, any target. 2.0 debounces only the handler passed to init, and only on .touchUpInside (SwiftyButton.swift:53, 71-84); actions you register yourself with addTarget(_:action:for:) are no longer throttled and must debounce themselves. The window is now the plain configurable property SwiftyButton.ignoreEventInterval (default 1.0; 0 disables)
SwiftyGlobal.modelName no longer maps known device identifiers to friendly names It now always returns the raw uname identifier for physical devices (e.g. "iPhone12,1" instead of "iPhone 11"); simulators still return "Simulator"
Third-party code that posts UIResponder.keyboardWillShowNotification / keyboardWillHideNotification from a background thread While a SwiftyAlertView is on screen it observes both notifications and asserts main-thread delivery (MainActor.assumeIsolated), trapping if violated instead of racing on unsynchronized state. 1.x observed the same notifications without the assertion, so an off-main post raced instead of trapping. Post keyboard notifications from the main thread, as UIKit itself does
Consumers still building in Swift 5 language mode lose a runtime guard that used to exist SwiftyLabel's property setters used to skip setNeedsDisplay() when called off the main thread (ten if Thread.isMainThread guards in the 1.x TextEditable extension). They no longer check, so they now call into UIKit off-main instead of silently no-op'ing. Swift 6-mode callers are unaffected — @MainActor turns the same mistake into a compile error. Swift 5-mode consumers should audit any code that mutated SwiftyLabel off the main thread

Fixed

  • B1 Force-unwrap crash in SwiftyGlobal.keyWindow when there is no key window
  • B2 A static initializer constructed a UINavigationController (a UIKit object), which could run on a background thread on first access
  • B3 ImageSettable's getter force-cast (as! CGImage) could crash, and the CGImage round trip lost scale and imageOrientation
  • B4 ImageCachePool used sync on a concurrent queue for write operations, causing data races with concurrent reads and the barrier-based writer
  • B5 Memory-usage accounting (UInt64) could underflow and trap
  • B6 The color-extraction sort predicate used <=, violating sort(by:)'s strict-weak-ordering requirement (undefined behavior)
  • B7 SwiftyPromise's internal state was read and written across threads with no synchronization; concretely, fire() could hand the same Operation to OperationQueue twice, crashing the process with an uncaught NSException
  • B8 SwiftyToastCenter's queue state was read and written across threads with no synchronization
  • B9 The thread pool's perpetual RunLoop operation was never actually enqueued, because init called add before assigning self.queue, and add reads the queue through currentQueue — still nil, so the operation was silently dropped. 2.0 fixes the assignment order and then removes the operation altogether, because once it genuinely ran it did far more harm than good:
    1. It made SwiftyThreadPool impossible to deallocate. The operation's closure strongly captures self, and its loop condition while !Thread.current.isCancelled never becomes true — cancel() sets the operation's flag, not the thread's. The operation therefore never finished, the strong reference was never released, and deinit (including its B18 observer removal) was unreachable dead code.
    2. It permanently occupied one thread of a pool whose concurrency floor is 3.
    3. It did nothing. The CFRunLoopSource it created has a nil perform callback, so waking it executes nothing, and no code anywhere in the repository ever posted work to it.
    4. Removing it restores 1.x's observable behaviour exactly, since it never ran there either.
      The assignment-order constraint itself still stands and is documented in init: anything enqueued from init in the future must come after self.queue = queue. Regression test: testPoolDeallocatesAfterCancel, which also dropped that test case's runtime from 5.6 s to 0.5 s — the old 5 s was a poll waiting for a deallocation that could never happen
  • B10 SwiftyThreadPool.stop() / restart() had inverted semantics (stop() resumed the queue, restart() suspended it)
  • B11 The always handler ran before the rest of the chain instead of after it
  • B12 Integer division in the color-extraction threshold check made the 0.3 cutoff ineffective; fixed by switching to floating-point division. Fixing this also exposed a second, related problem in the same algorithm — see the dominant-color sort direction entry under Breaking Changes
  • B13 isDistinct's inner near-gray check was dead code, unconditionally overwritten by the following line
  • B14 A per-call local NSLock in UIImage.load provided no real mutual exclusion — pure overhead, since a lock created fresh on every call never contends with anything. This fix has no regression test of its own, permanently. A lock that never contends with anything is behaviourally indistinguishable from no lock at all, so removing it changes nothing observable; UIImage.load's thread safety came, and still comes, from the shared lock inside ImageCachePool (B4). Mutation testing confirms the two are inseparable: putting the local lock back leaves testConcurrentLoadIsSerializedByTheCachePoolLock green, while removing ImageCachePool's shared lock crashes it whether or not the local lock is restored — which is precisely B14's point. That test is therefore a regression test for B4, not for B14; it is documented as such at its definition
  • B15 Implicit, process-wide swizzling of backgroundColor/alpha on every UIView in the host app, triggered lazily by the first call to addTo(_:)
  • B16 A SwiftyPromise chain with no .catch() never started and leaked (see the new start() API)
  • B17 SwiftyGlobal's screen/status-bar metrics were cached once in static properties and went stale across rotation, split-screen, and multi-window
  • B18 deinit called removeObserver(self), which is a no-op for observers registered via the block-based addObserver(forName:object:queue:using:) API — the token returned by that call is the real observer, not self. Fixed by storing and removing the token instead. This fix is correctness-only and not user-visible in practice: both affected types (ImageCachePool.default, SwiftyThreadPool.default) are singletons that live for the process's lifetime, so their deinit was never expected to run in production. SwiftyThreadPool's deinit was, on top of that, unreachable altogether because of the perpetual RunLoop operation described under B9; now that the operation is gone, the fix can actually execute there too. ImageCachePool's deinit does run for the short-lived instances created in tests
  • B19 UIColor.hex(String) force-unwrapped the result of UInt(_:radix:), crashing on syntactically-valid-length but non-hexadecimal input (e.g. hex("zzz"))
  • B20 SwiftyAlertView registered both of its keyboard observers for UIResponder.keyboardWillShowNotification. The second observer's body is the keyboard-hide logic (it checks keyboardHasBeenShown and restores the saved frame), so both ran on the same dispatch, in registration order: the first shifted the alert clear of the keyboard, the second immediately put it back and cleared the saved origin. Net displacement on every keyboard appearance: exactly zero. Meanwhile keyboardWillHideNotification was not observed at all. The second observer now registers for keyboardWillHideNotification.
    This means keyboard avoidance in SwiftyAlertView has never worked in any released version, and 2.0 is the first release in which an alert with addTextField() actually moves out of the keyboard's way. Regression tests: testKeyboardAvoidanceFollowsShowHideLifecycle (unit) and testAlertWithTextFieldAvoidsKeyboard (UI).
    Registration also moved off viewDidAppear / viewDidDisappear to showTitle / hideView(). This was not because the appearance callbacks fail to fire — measured on the iOS 26.5 simulator, they do fire, even though showTitle attaches the alert with a bare addSubview onto the key window, with no presentation, no view-controller containment, and no rootViewController relationship. Classic UIKit rules say a controller in that position should receive no appearance callbacks; the observed behaviour differs, and the library no longer depends on which is right. The new sites also buy two concrete things: registration pairs deterministically with the self-retain/release lifecycle this type already runs through selfReference (the no-key-window early return precedes registration, so the pairing invariant holds on every exit path), and hideView() unregisters before the dismiss animation, so a late keyboard notification cannot shove an alert that is already on its way out.
  • B21 The keyboard observers were removed with NotificationCenter.default.removeObserver(self, name:), which is a no-op for observers registered through the block-based addObserver(forName:object:queue:using:) API — the token that call returns is the observer, not self. Same root cause as B18, but on a type whose instances genuinely do deallocate: registrations would have accumulated two per alert shown, for the lifetime of the process, with every keyboard event firing all of them (and, combined with the MainActor.assumeIsolated in each block, a growing number of trap sites for any off-main keyboard post). Fixed by storing both tokens and removing them by token — the same shape now used at all three observer sites in the library. Re-registering also removes any previous pair first, so showing the same alert twice cannot strand a token. Unlike B18, this leak was live in shipped builds: the registration point did run, so every alert an app presented left two observers behind permanently.
    Unregistering also clears the keyboard bookkeeping (tmpContentViewFrameOrigin, tmpCircleViewFrameOrigin, keyboardHasBeenShown). Those three are written by the show-observer and cleared by the hide-observer — but hideView() unregisters before the dismiss animation, so dismissing an alert while the keyboard was still up left all three stale, the surviving half of the state waiting on a cleaner that no longer existed. Regression test: testHidingWhileKeyboardIsUpLeavesNoStaleKeyboardState
  • B22 SwiftyToast placed the toast entirely off-screen in landscape. layoutView swapped containerSize's width and height before computing the position — correct for iOS 7 and earlier, where the window did not rotate with the interface and its bounds stayed portrait-sized, but wrong since iOS 8, where window.bounds already reflects the interface orientation. Swapping again rotated the result back into a portrait coordinate system. Measured on an iPhone 17 simulator (portrait window 402×874, landscape 874×402), the landscape toast landed at (132.5, 827) in a window only 402 points tall: nothing was visible at all. It now lands at (368.5, 355), centred against the bottom of the landscape window. Same defect class as B17 and the UIScreen.main removal — a static assumption about screen geometry that stopped holding many releases ago. Found by the UI tests added in this release; no prior test could reach it. Regression test: testToastFollowsDeviceOrientation.
    Fixing the swap alone was not enough, because the frame was still computed in init rather than at display time. SwiftyToastCenter is a serial queue and a toast takes ~3.5 s to play, so a toast created while another is showing waits before its show() runs — and a frame computed at construction is stale by then. That reproduced the same symptom through two other routes: rotating while a toast sits in the queue (it appears with the pre-rotation geometry, entirely off-screen in landscape), and constructing a toast before any key window exists (containerSize degenerates to .zero, giving a negative origin — measured at (-109, -57, 218, 27)). The layout now runs inside show(), after the guard let window, which fixes both and removes the ?? .zero degenerate branch entirely. Regression test: testGeometryIsNotComputedAtConstructionTime.
    A toast already on screen now also follows rotation, via an autoresizingMask on the toast view: the width delta is split between two equal flexible side margins (so it stays horizontally centred) and the height delta is absorbed by a flexible top margin (so the bottom gap is preserved). Regression test: testToastLabelReanchorsItselfWhenWindowResizes

Added

  • SwiftyGlobal.safeAreaInsets
  • SwiftyThreadPool.isSuspended
  • SwiftyPromise.start() — a chain terminator that doesn't require an error handler
  • SwiftyPromise.value() async throws -> T? — an async/await bridge. It registers its error handler in a slot of its own, separate from the one catch(_:) writes, so the two can be used together in either order: catch(_:) keeps its documented replace semantics for your handler without ever displacing an outstanding await, and value() never discards a handler you registered earlier. An error reaches your catch handler first, then resumes the await by rethrowing
  • SwiftyButton.ignoreEventInterval — configurable double-tap debounce window (default 1.0)
  • SwiftyUI.enableGlobalOpaqueOptimization() — opt-in, process-wide, and irreversible; see Breaking Changes
  • ImageSettable.applyBackgroundImage(_:) — pushes a UIImage onto layer.contents and updates isOpaque. It replaces the 1.x protocol extension's backgroundImage implementation, and conformers must call it themselves from their own backgroundImage didSet; see Breaking Changes and the README example
  • SwiftyToast.accessibilityIdentifier — the identifier set on the toast view. SwiftyLabel draws its own text through TextKit rather than subclassing UILabel, so a toast was a silent coloured rectangle to VoiceOver and invisible to UI automation; it now exposes itself as a static-text accessibility element. The constant is declared nonisolated: SwiftyToast is @MainActor-isolated and its members inherit that isolation, which would have made this one unreferenceable from the nonisolated UI-automation code it exists to serve
  • Unit tests, UI tests, and GitHub Actions CI

Other

  • Removed the 1.9 MB SwiftyUI.framework binary that was checked into the repository (armv7/arm64 only, not an xcframework). Distribution now relies solely on source integration via SPM and CocoaPods
  • Split the 1027-line SwiftyAlert.swift into SwiftyAlertView.swift, SwiftyAlertStyle.swift, and SwiftyAlertIcons.swift by responsibility
  • Sank TextEditable's and ImageCacheable's associated-object storage into real stored properties, removing Objective-C runtime overhead on every read
  • ImageCachePool now orders its purge by a monotonic access counter instead of a Date stamp. Two accesses inside the same clock granularity produced identical timestamps — common, since a cache hit is far shorter than a clock tick — which left the eviction order arbitrary among the tied entries. A counter cannot tie
  • Repository hygiene: removed .travis.yml (CI moved to GitHub Actions), the stray 2017 iOSExample/iOSExample/ legacy copy, and checked-in .DS_Store files
  • Shared the iOSExample scheme. Scripts/uitest.sh and CI both pass -scheme iOSExample, but only SwiftyUI.xcscheme was tracked; the other one was being autocreated by xcodebuild on the fly — an undeclared dependency whose contents are implicit and drift across Xcode versions
  • Three standing consistency checks, all wired into CI, each written because the corresponding mistake had already happened at least once:
    • Scripts/check-readme.sh — extracts every Swift block from the README and compiles it, each block in its own scope with nothing in scope but UIKit and SwiftyUI, so non-self-contained examples fail. It also checks that every type named in the prose exists and is public. A block that is deliberately not self-contained must opt out with an explicit <!-- readme-check: skip … --> marker, and the script reports how many it skipped
    • Scripts/check-shared-schemes.sh — copies the project to a temp directory, disables scheme autocreation in the copy, and asserts the schemes the scripts rely on still resolve
    • Scripts/check-uitest-restore.sh — asserts Scripts/uitest.sh restores the machine-global Simulator preference it has to change (ConnectHardwareKeyboard), including the case where the key did not exist beforehand