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
- Run the view controller below.
- 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?
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
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 aUIBezierPathbuilt byUIBezierPath.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'scornerRadius/cornerCurve. When a continuous corner has to shrink to fit, Core Animation andUIBezierPathshrink 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
UIViewwith the same frame,cornerRadius,cornerCurveand 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
Code example that reproduces the issue
How do you display panel(s)?
How many panels do you displays?
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:
The per-shadow layers, their mask and both
UIBezierPath.path(roundedRect:…)helpers can then go.containerView.layer.cornerCurve = appearance.cornerCurvecurrently lives in the mask code, so it moves intoupdateCornerRadius().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:
shadowsis drawn. Several shadows could each be cast by a wrapper view around the container, still with no path.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.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.hiddenis 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.2Installation method
Sources/for the reproiOS version(s)
iOS 27.0 simulator (iPhone 18 Pro)
Xcode version
27.0