The Swift truncatingRemainder Trap That Took Me Five App Review Rejections
TL;DR: (-2310).truncatingRemainder(dividingBy: 360) returns -150, not 210. If you're using it to wrap a rotation angle that accumulates across multiple animations, your "wheel" will drift off-target. Reviewer will see the pointer land on "tacos" but your result label says "pizza" — and you'll get a 2.1(a) rejection with a screenshot you can't argue with.
The Setup: A Decision Wheel That Should Have Been Trivial
I shipped a free iOS app called AutoChoice — spin a wheel to pick lunch when you can't decide. The wheel has 8 slices. You tap "Spin," it animates ~3 rotations + lands on a target. Trivial.
The math:
// Pseudocode of my v1.0.0 ship
func spin() {
let target = Double.random(in: 0..<360)
let rounds = 3
let next = currentRotation.truncatingRemainder(dividingBy: 360)
- Double(rounds) * 360
+ target
withAnimation(.easeOut(duration: 3)) {
currentRotation = next
}
selectedSlice = sliceForAngle(target)
}
Looks fine, right? It worked in the simulator. It worked when I tapped it 10 times in a row. It even worked at TestFlight stage with three external testers.
Then Apple reviewed it. Reject. 2.1(a) Performance — Accuracy.
"Upon launching the app, we found that the result displayed does not match the pointer position on the wheel."
Attached: a screenshot of the wheel after a spin. Pointer clearly on the "Sushi" slice. Result label: "Tacos."
Five Rejections of Increasingly Confused Debugging
Round 1: I blamed the animation. Maybe the wheel snapped past the target. Added .animation(.easeOut(duration: 3)) with explicit completion. Rejected again.
Round 2: I blamed the slice-to-angle mapping. Triple-checked sliceForAngle(). Wrote unit tests. All green. Rejected again.
Round 3: I blamed CoreAnimation rounding. Forced currentRotation to an integer at the end. Rejected again.
Round 4: I added a print of every variable and shipped to TestFlight. Tested 50 times. Couldn't reproduce. Asked friends to test. Couldn't reproduce. Submitted. Rejected again with another screenshot.
Round 5: I sat down and asked myself: what is different between my simulator and the reviewer's device?
The reviewer was tapping "Spin" many more times in a row than anyone else. Each tap adds another ~3 rotations to currentRotation. After 20 taps, currentRotation is around -21,000 degrees.
Let me show you what Swift does with that:
let r: Double = -21_000 + 150 // accumulated rotation, target=150
r.truncatingRemainder(dividingBy: 360) // → -210, NOT 150
That's the bug. truncatingRemainder keeps the sign of the dividend. For positive r, it's the mathematical mod. For negative r, it returns a negative residual. So my "reset to target via mod 360" produced a rotation 360 - target off-target — and the wheel landed exactly opposite the intended slice.
The reviewer wasn't lucky — they were thorough.
Why the Tests Didn't Catch It
I had unit tests for sliceForAngle(). I had UI tests that tapped Spin once and verified the label matched. None of them tapped Spin enough times to make currentRotation go negative.
The bug only manifests when currentRotation < 0 AND target ≠ 0 AND currentRotation is not already a multiple of 360.
That's roughly: the 4th spin onward. Most testers stopped after 2-3 spins because the joke wore off.
The Fix: Floor-Divide Anchor
The mathematical mod operation in Swift is not truncatingRemainder. There isn't a built-in. You write it yourself:
extension Double {
/// Mathematical mod (Euclidean) — always returns a value in [0, divisor).
func euclidMod(_ divisor: Double) -> Double {
let r = self.truncatingRemainder(dividingBy: divisor)
return r >= 0 ? r : r + divisor
}
}
But for the rotation case specifically, the cleaner fix is the floor-divide anchor:
func spin() {
let target = Double.random(in: 0..<360)
let rounds = 3
// Anchor to nearest multiple of 360 BELOW currentRotation, then add rotations + target.
let baseAnchor = (currentRotation / 360).rounded(.down) * 360
let next = baseAnchor - Double(rounds) * 360 + target
withAnimation(.easeOut(duration: 3)) {
currentRotation = next
}
selectedSlice = sliceForAngle(target)
}
Why this works: (x / 360).rounded(.down) * 360 is the largest multiple of 360 that is ≤ x. Adding target ∈ [0, 360) gives a final rotation whose (mod 360) == target regardless of sign. Add the -rounds * 360 to make the animation go forward several full rotations before settling.
Mathematically equivalent. Pictorially identical animation. Bug-free.
The Generalized Rule
Never use truncatingRemainder as a "reset to canonical range" operation on a value that can be negative.
Cases I now check for in code review:
| Use case | Bad | Good |
|---|---|---|
| Wrap a rotation angle | angle.truncatingRemainder(360) |
angle.euclidMod(360) |
| Wrap a hue value | hue.truncatingRemainder(1.0) |
hue.euclidMod(1.0) |
| Modular cursor (carousel) | index.truncatingRemainder(count) |
(index % count + count) % count |
| Time-of-day clock | seconds.truncatingRemainder(86400) |
seconds.euclidMod(86400) |
The rule is: if your accumulator can ever become negative, and you want a non-negative residual, truncatingRemainder lies. Use Euclidean mod or floor-divide anchor.
For integers, the operator % has the same trap. (-5) % 3 == -2 in Swift, not 1. Same pattern, same fix.
Why This Lesson Is Worth Sharing
I shipped 4 apps in 6 weeks. I have a CS degree. I've written modular arithmetic in five languages. And I still ate 5 App Store rejections because one Swift built-in did the un-mathematical thing silently.
The rejections cost me:
- ~12 days of calendar time (Apple review queue + my re-submission delays)
- 5 round-trips of "what could possibly be wrong" anxiety
- A growing inbox of "thanks, but we found new bugs in your latest submission" emails
What ultimately fixed it: actually reading what truncatingRemainder does in the documentation. The docstring says:
"The result of
r.truncatingRemainder(dividingBy: x)has the same sign asrand has a magnitude less than|x|."
Same sign as r. I read that line five times before I believed it. I had assumed for 15 years that mod operations always returned non-negative values for non-negative divisors. Swift makes a different choice.
Read the docstring of every built-in math function you assume you know. It might be doing the IEEE 754 thing instead of the textbook thing.
Reproducer Code (Drop Into a Playground)
import Foundation
let testCases: [(Double, Double, Double)] = [
// (input, divisor, "expected mathematical mod")
(-2310, 360, 210),
(-21_000 + 150, 360, 150),
(-5, 3, 1),
(350, 360, 350), // positive, works
]
for (r, d, expected) in testCases {
let truncating = r.truncatingRemainder(dividingBy: d)
let euclidean: Double = {
let m = r.truncatingRemainder(dividingBy: d)
return m >= 0 ? m : m + d
}()
let match = abs(euclidean - expected) < 1e-9
print("r=\(r), divisor=\(d)")
print(" truncatingRemainder: \(truncating) ← \(truncating == expected ? "OK" : "WRONG")")
print(" euclidean mod: \(euclidean) ← \(match ? "OK" : "WRONG")")
print("")
}
If you've ever shipped a Swift app with rotation, hue, cursor, or time arithmetic — run this. If your code uses truncatingRemainder and the accumulator can go negative, you have a latent reviewer-only bug.
Bottom Line
The reviewer wasn't being mean. The reviewer was the only person who tapped my button enough times to expose IEEE 754's signed-residual policy interacting with my naive expectation of mathematical mod.
Five rejections taught me to read truncatingRemainder's actual contract. I'm publishing this so you can save 5 rejections of your own.
If this helped, drop a follow — I post indie-iOS-dev gotchas like this every few days.
Cover image prompt (1000×420):
"A spinning carnival prize wheel viewed from above, pointer landing on a slice labeled 'sushi' but the player's score card below says 'tacos.' Minimalist editorial illustration, muted colors, slight ironic tone."
Suggested tags: swift, ios, gotcha, appstore, debugging
Internal cross-links:
- Previous article: "The Bundle.for Trap in iOS Tests with @testable import" (dev.to 94)
- Series: "Indie iOS Lessons from 4 Apps in 6 Weeks"
Top comments (0)