Skip to content

Surface shadow drops out at the corners of a short panel with continuous corners #691

Description

@fawzirifai
Image

Description

With cornerCurve = .continuous, the surface's shadow nearly disappears at the corners once the container is too short for the whole continuous corner: under about 98pt at a 32pt radius. Straight edges are fine, and a taller container is fine.

SurfaceView.updateShadow() casts each shadow from a UIBezierPath built by UIBezierPath.path(roundedRect:appearance:), and cuts the container out of it with an even-odd mask made from the same path. The container itself is clipped by its layer's cornerRadius / cornerCurve. When a continuous corner has to shrink to fit, Core Animation and UIBezierPath shrink it differently: the path comes out rounder than the container. So at each corner the shadow is cast from a shape that sits inside the container and barely shows past it.

Expected behavior

The shadow follows the container's edge evenly all the way round, as a plain UIView with the same frame, cornerRadius, cornerCurve and layer shadow does.

Actual behavior

At each corner the shadow thins out to almost nothing. Compared pixel by pixel against that plain UIView (default shadow, 80pt tall): about 30,000 pixels of the shadow differ, by up to 14 levels out of 255, and about 5,300 pixels on the container's edge. At 130pt the path and the container agree, and the shadow's difference falls to at most 3 levels.

Setting cornerConfiguration (#664) makes it worse rather than better: path(roundedRect:view:) insets the path 4pt on every side, so the default 3pt shadow is almost entirely hidden behind the container at every height.

Steps to reproduce

  1. Run the view controller below.
  2. Look at the panel's corners.

Code example that reproduces the issue

import UIKit
import FloatingPanel

final class ShadowCornersViewController: UIViewController {
    private let fpc = FloatingPanelController()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor(white: 0.9, alpha: 1)

        let appearance = SurfaceAppearance()
        appearance.cornerRadius = 32
        appearance.cornerCurve = .continuous
        // Exaggerated so the corners are easy to see; the default 0.2 shows the same gap.
        appearance.shadows.forEach { $0.opacity = 0.6 }
        fpc.surfaceView.appearance = appearance
        fpc.surfaceView.containerMargins = UIEdgeInsets(top: 0, left: 16, bottom: 34, right: 16)
        fpc.layout = ShortCardLayout()
        fpc.set(contentViewController: UIViewController())
        fpc.addPanel(toParent: self)
    }
}

/// An 80pt card: the anchor is the card's height plus the 34pt bottom margin.
final class ShortCardLayout: FloatingPanelLayout {
    let position: FloatingPanelPosition = .bottom
    let initialState: FloatingPanelState = .tip
    let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
        .tip: FloatingPanelLayoutAnchor(absoluteInset: 80 + 34, edge: .bottom, referenceGuide: .superview)
    ]
}

How do you display panel(s)?

  • Add as child view controllers

How many panels do you displays?

  • 1

Suggested fix

Let the surface cast the shadow itself, with no shadow path. Core Animation then derives the shadow from what the container actually draws, so there is no second copy of the corner to disagree with it:

private func updateShadow() {
    let shadow = appearance.shadows.first { !$0.hidden }
    layer.shadowPath = nil
    layer.shadowColor = shadow?.color.cgColor
    layer.shadowOffset = shadow?.offset ?? .zero
    layer.shadowRadius = shadow?.radius ?? 0
    layer.shadowOpacity = shadow?.opacity ?? 0
}

The per-shadow layers, their mask and both UIBezierPath.path(roundedRect:…) helpers can then go. containerView.layer.cornerCurve = appearance.cornerCurve currently lives in the mask code, so it moves into updateCornerRadius().

Measured against the same plain UIView, this is pixel-identical at 80, 95 and 130pt, both at the default opacity and at 0.6.

It changes what the API does, which is your call:

  • One shadow. A layer casts one shadow, so only the first visible entry in shadows is drawn. Several shadows could each be cast by a wrapper view around the container, still with no path.
  • No spread. Spread needs the outline grown outward, and there's no public way to get Core Animation's continuous corner path, so it would need a hand-built path again.
  • Translucent backgrounds. A translucent container would show its own shadow through it, which the current mask prevents.

I also tried keeping one layer per shadow but filling it and copying the container's corner properties onto it. The shadow then matches exactly, but about 1,000 anti-aliased pixels on the container's curved edge come out lighter: the edge is drawn twice, once by the container and once by the filled layer beneath it. Filling that layer black instead turns the same pixels darker, which confirms the cause.

Separately, Shadow.hidden is never read, so setting it has no effect today. The fix above honours it.

Happy to open a PR if this direction works for you.

Environment

Library version

main (de51f81); also 3.2.2

Installation method

  • Source, built directly from Sources/ for the repro

iOS version(s)

iOS 27.0 simulator (iPhone 18 Pro)

Xcode version

27.0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions