DEV Community

孫昊
孫昊

Posted on Originally published at jiejuefuyou.github.io

The Swift truncatingRemainder Trap That Took Me Five App Review Rejections

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 as r and 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("")
}
Enter fullscreen mode Exit fullscreen mode

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"

Shipping iOS apps on Windows? I wrote 5 Python lints that catch the bugs Apple will catch for you. The ASC API Toolkit has them all: jiejuefuyou.gumroad.com/l/vszsui ($499, 60+ scripts).

Or: book a 15-min iOS rejection audit -- $249, 14-day refund.

Top comments (0)