WIP
This commit is contained in:
parent
fbebd03f88
commit
0ccec4b6b6
11 changed files with 834 additions and 630 deletions
|
|
@ -145,6 +145,7 @@ public protocol NavigationBar: ASDisplayNode {
|
|||
var clippingNode: SparseNode { get }
|
||||
|
||||
var backgroundView: UIView { get }
|
||||
var customOverBackgroundContentSubview: UIView? { get set }
|
||||
var contentNode: NavigationBarContentNode? { get }
|
||||
var secondaryContentNode: ASDisplayNode? { get }
|
||||
var secondaryContentNodeDisplayFraction: CGFloat { get set }
|
||||
|
|
|
|||
|
|
@ -88,21 +88,37 @@ public final class ChatListTabsComponent: Component {
|
|||
}
|
||||
}
|
||||
|
||||
public final class View: UIView {
|
||||
private struct LayoutData {
|
||||
var size: CGSize
|
||||
var selectedItemFrame: CGRect
|
||||
|
||||
init(size: CGSize, selectedItemFrame: CGRect) {
|
||||
self.size = size
|
||||
self.selectedItemFrame = selectedItemFrame
|
||||
}
|
||||
}
|
||||
|
||||
public final class View: UIView, UIScrollViewDelegate {
|
||||
private let lensView: LiquidLensView
|
||||
private let scrollView: ScrollView
|
||||
private let selectionView: UIImageView
|
||||
private var itemViews: [Tab.Id: ComponentView<Empty>] = [:]
|
||||
|
||||
private var ignoreScrolling: Bool = false
|
||||
private var tabSwitchFraction: CGFloat = 0.0
|
||||
private var temporaryLiftTimer: Foundation.Timer?
|
||||
|
||||
private var layoutData: LayoutData?
|
||||
|
||||
private var component: ChatListTabsComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.lensView = LiquidLensView(kind: .noContainer)
|
||||
self.scrollView = ScrollView()
|
||||
|
||||
self.selectionView = UIImageView()
|
||||
self.scrollView.addSubview(self.selectionView)
|
||||
//self.scrollView.addSubview(self.selectionView)
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
|
|
@ -116,21 +132,58 @@ public final class ChatListTabsComponent: Component {
|
|||
self.scrollView.alwaysBounceVertical = false
|
||||
self.scrollView.scrollsToTop = false
|
||||
self.scrollView.clipsToBounds = false
|
||||
self.addSubview(self.scrollView)
|
||||
self.scrollView.delegate = self
|
||||
|
||||
self.addSubview(self.lensView)
|
||||
|
||||
self.lensView.contentView.addSubview(self.scrollView)
|
||||
}
|
||||
|
||||
required public init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
return self.scrollView.hitTest(self.convert(point, to: self.scrollView), with: event)
|
||||
}
|
||||
|
||||
public func updateTabSwitchFraction(fraction: CGFloat, transition: ComponentTransition) {
|
||||
self.tabSwitchFraction = -fraction
|
||||
self.state?.updated(transition: transition, isLocal: true)
|
||||
}
|
||||
|
||||
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
if self.ignoreScrolling {
|
||||
return
|
||||
}
|
||||
self.updateScrolling(transition: .immediate)
|
||||
}
|
||||
|
||||
private func updateScrolling(transition: ComponentTransition) {
|
||||
guard let component = self.component, let layoutData = self.layoutData else {
|
||||
return
|
||||
}
|
||||
self.lensView.update(size: layoutData.size, selectionX: -self.scrollView.contentOffset.x + layoutData.selectedItemFrame.minX, selectionWidth: layoutData.selectedItemFrame.width, isDark: component.theme.overallDarkAppearance, isLifted: self.temporaryLiftTimer != nil, transition: transition)
|
||||
}
|
||||
|
||||
func update(component: ChatListTabsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||||
if self.component?.selectedTab != component.selectedTab {
|
||||
self.tabSwitchFraction = 0.0
|
||||
|
||||
self.temporaryLiftTimer?.invalidate()
|
||||
self.temporaryLiftTimer = nil
|
||||
|
||||
if !transition.animation.isImmediate {
|
||||
self.temporaryLiftTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] timer in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.temporaryLiftTimer === timer {
|
||||
self.temporaryLiftTimer = nil
|
||||
self.state?.updated(transition: .spring(duration: 0.5))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
self.component = component
|
||||
|
|
@ -252,12 +305,23 @@ public final class ChatListTabsComponent: Component {
|
|||
self.selectionView.isHidden = true
|
||||
}
|
||||
|
||||
self.layoutData = LayoutData(
|
||||
size: size,
|
||||
selectedItemFrame: selectedItemFrame ?? CGRect()
|
||||
)
|
||||
|
||||
self.ignoreScrolling = true
|
||||
let contentSize = CGSize(width: contentWidth, height: size.height)
|
||||
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: size))
|
||||
if self.scrollView.contentSize != contentSize {
|
||||
self.scrollView.contentSize = contentSize
|
||||
}
|
||||
|
||||
transition.setFrame(view: self.lensView, frame: CGRect(origin: CGPoint(), size: size))
|
||||
self.ignoreScrolling = false
|
||||
|
||||
self.updateScrolling(transition: transition)
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,573 +4,6 @@ import Display
|
|||
import ComponentFlow
|
||||
import MeshTransform
|
||||
|
||||
/// Bezier control points for displacement easing curve
|
||||
private struct DisplacementBezier {
|
||||
var x1: CGFloat
|
||||
var y1: CGFloat
|
||||
var x2: CGFloat
|
||||
var y2: CGFloat
|
||||
}
|
||||
|
||||
/// Computes signed distance from a point to the edge of a rounded rectangle.
|
||||
/// Returns negative inside, zero on edge, positive outside.
|
||||
/// All values in points.
|
||||
private func roundedRectSDF(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
|
||||
// Center the point (SDF formula assumes center at origin)
|
||||
let px = x - width / 2
|
||||
let py = y - height / 2
|
||||
|
||||
// Half extents of the box
|
||||
let bx = width / 2
|
||||
let by = height / 2
|
||||
|
||||
// Standard rounded box SDF (Inigo Quilez formula)
|
||||
let qx = abs(px) - bx + cornerRadius
|
||||
let qy = abs(py) - by + cornerRadius
|
||||
|
||||
let outsideDist = hypot(max(qx, 0), max(qy, 0))
|
||||
let insideDist = min(max(qx, qy), 0)
|
||||
|
||||
return outsideDist + insideDist - cornerRadius
|
||||
}
|
||||
|
||||
/// Computes the gradient (outward normal) of the rounded rect SDF.
|
||||
/// Returns normalized direction perpendicular to the nearest edge point.
|
||||
private func roundedRectGradient(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> (nx: CGFloat, ny: CGFloat) {
|
||||
// Center the point
|
||||
let px = x - width / 2
|
||||
let py = y - height / 2
|
||||
|
||||
// Half extents
|
||||
let bx = width / 2
|
||||
let by = height / 2
|
||||
|
||||
// q values from SDF formula
|
||||
let qx = abs(px) - bx + cornerRadius
|
||||
let qy = abs(py) - by + cornerRadius
|
||||
|
||||
var nx: CGFloat = 0
|
||||
var ny: CGFloat = 0
|
||||
|
||||
if qx > 0 && qy > 0 {
|
||||
// Corner region - normal points radially from corner arc center
|
||||
let d = hypot(qx, qy)
|
||||
if d > 0 {
|
||||
nx = qx / d
|
||||
ny = qy / d
|
||||
}
|
||||
} else if qx > qy {
|
||||
// Nearest point is on vertical edge (left or right)
|
||||
nx = 1
|
||||
ny = 0
|
||||
} else {
|
||||
// Nearest point is on horizontal edge (top or bottom)
|
||||
nx = 0
|
||||
ny = 1
|
||||
}
|
||||
|
||||
// Restore sign based on which side of center we're on
|
||||
if px < 0 { nx = -nx }
|
||||
if py < 0 { ny = -ny }
|
||||
|
||||
return (nx, ny)
|
||||
}
|
||||
|
||||
/// Generates a displacement map image as a signed distance field from rounded rect edges.
|
||||
/// - edgeDistance: The distance (in points) over which displacement is applied
|
||||
/// - R channel: X displacement (127 = neutral, 0 = max left, 255 = max right)
|
||||
/// - G channel: Y displacement (127 = neutral, 0 = max up, 255 = max down)
|
||||
/// - B channel: Unused (always 0)
|
||||
/// Displacement is maximum at the edge and fades linearly to zero at edgeDistance.
|
||||
/// Actual displacement magnitude is applied when sampling the map.
|
||||
private func generateDisplacementMap(size: CGSize, cornerRadius: CGFloat, edgeDistance: CGFloat, scale: CGFloat) -> CGImage? {
|
||||
let width = Int(size.width * scale)
|
||||
let height = Int(size.height * scale)
|
||||
|
||||
// Clamp corner radius
|
||||
let maxCornerRadius = min(size.width, size.height) / 2.0
|
||||
let clampedRadius = min(cornerRadius, maxCornerRadius)
|
||||
|
||||
// Create bitmap context
|
||||
var pixelData = [UInt8](repeating: 0, count: width * height * 4)
|
||||
|
||||
for py in 0 ..< height {
|
||||
for px in 0 ..< width {
|
||||
// Convert pixel to point coordinates
|
||||
let x = CGFloat(px) / scale
|
||||
let y = CGFloat(py) / scale
|
||||
|
||||
// Get signed distance (negative inside, positive outside)
|
||||
let sdf = roundedRectSDF(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
// Get gradient (outward normal direction)
|
||||
let (nx, ny) = roundedRectGradient(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
// Inward normal (content moves away from edge, toward center)
|
||||
let inwardX = -nx
|
||||
let inwardY = -ny
|
||||
|
||||
// Distance from edge (positive inside the shape)
|
||||
let distFromEdge = -sdf
|
||||
|
||||
// Weight: 1 at edge, 0 at edgeDistance (linear falloff)
|
||||
let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance))
|
||||
|
||||
// Displacement modulated by distance from edge
|
||||
let displacementX = inwardX * weight
|
||||
let displacementY = inwardY * weight
|
||||
|
||||
// Encode in R/G: 127 = neutral, map -1..1 to 0..254
|
||||
let r = UInt8(max(0, min(255, Int(127 + displacementX * 127))))
|
||||
let g = UInt8(max(0, min(255, Int(127 + displacementY * 127))))
|
||||
|
||||
let idx = (py * width + px) * 4
|
||||
pixelData[idx + 0] = r // X displacement
|
||||
pixelData[idx + 1] = g // Y displacement
|
||||
pixelData[idx + 2] = 0 // Unused
|
||||
pixelData[idx + 3] = 255 // A
|
||||
}
|
||||
}
|
||||
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: &pixelData,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
/// Samples displacement from a displacement map with bilinear interpolation and bezier easing
|
||||
/// - Parameters:
|
||||
/// - x, y: Coordinates in the displacement map's pixel space
|
||||
/// - pixels: Pointer to displacement map pixel data
|
||||
/// - width, height: Displacement map dimensions
|
||||
/// - bytesPerRow, bytesPerPixel: Displacement map layout
|
||||
/// - bezier: Bezier control points for easing curve
|
||||
/// - Returns: Displacement (dx, dy) in range -1..1 with bezier easing applied
|
||||
private func sampleDisplacement(
|
||||
x: CGFloat,
|
||||
y: CGFloat,
|
||||
pixels: UnsafePointer<UInt8>,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bytesPerRow: Int,
|
||||
bytesPerPixel: Int,
|
||||
bezier: DisplacementBezier
|
||||
) -> (dx: CGFloat, dy: CGFloat) {
|
||||
let clampedX = max(0, min(CGFloat(width - 1), x))
|
||||
let clampedY = max(0, min(CGFloat(height - 1), y))
|
||||
|
||||
let x0 = Int(clampedX)
|
||||
let y0 = Int(clampedY)
|
||||
let x1 = min(x0 + 1, width - 1)
|
||||
let y1 = min(y0 + 1, height - 1)
|
||||
|
||||
let fx = clampedX - CGFloat(x0)
|
||||
let fy = clampedY - CGFloat(y0)
|
||||
|
||||
func sample(_ sx: Int, _ sy: Int) -> (r: CGFloat, g: CGFloat) {
|
||||
let offset = sy * bytesPerRow + sx * bytesPerPixel
|
||||
return (CGFloat(pixels[offset + 0]), CGFloat(pixels[offset + 1]))
|
||||
}
|
||||
|
||||
let c00 = sample(x0, y0)
|
||||
let c10 = sample(x1, y0)
|
||||
let c01 = sample(x0, y1)
|
||||
let c11 = sample(x1, y1)
|
||||
|
||||
let r = (c00.r * (1 - fx) + c10.r * fx) * (1 - fy) + (c01.r * (1 - fx) + c11.r * fx) * fy
|
||||
let g = (c00.g * (1 - fx) + c10.g * fx) * (1 - fy) + (c01.g * (1 - fx) + c11.g * fx) * fy
|
||||
|
||||
// Decode: 127 = neutral, map 0..254 to -1..1
|
||||
var dx = (r - 127.0) / 127.0
|
||||
var dy = (g - 127.0) / 127.0
|
||||
|
||||
// Apply bezier easing to vector magnitude, preserving direction
|
||||
let mag = hypot(dx, dy)
|
||||
if mag > 0 {
|
||||
let newMag = bezierPoint(bezier.x1, bezier.y1, bezier.x2, bezier.y2, mag)
|
||||
let scale = newMag / mag
|
||||
dx *= scale
|
||||
dy *= scale
|
||||
}
|
||||
|
||||
return (dx, dy)
|
||||
}
|
||||
|
||||
/// Generates a glass mesh with corner-aware topology.
|
||||
/// - 4 radial corner wedges sampled in polar space
|
||||
/// - 4 edge strips aligned with the rectangle sides
|
||||
/// - 1 center patch
|
||||
/// Corner/edge seams share the same coordinates (but do not reuse vertices) so
|
||||
/// the neighbouring faces fit perfectly without T-junctions.
|
||||
private func generateGlassMeshFromDisplacementMap(
|
||||
size: CGSize,
|
||||
cornerRadius: CGFloat,
|
||||
displacementMap: CGImage,
|
||||
displacementMagnitudeU: CGFloat,
|
||||
displacementMagnitudeV: CGFloat,
|
||||
cornerResolution: Int,
|
||||
outerEdgeDistance: CGFloat,
|
||||
bezier: DisplacementBezier,
|
||||
generateWireframe: Bool = false
|
||||
) -> (mesh: MeshTransform, wireframe: CGPath?) {
|
||||
guard let dispDataProvider = displacementMap.dataProvider,
|
||||
let dispData = dispDataProvider.data,
|
||||
let dispPixels = CFDataGetBytePtr(dispData) else {
|
||||
return (mesh: MeshTransform(), wireframe: nil)
|
||||
}
|
||||
|
||||
let dispWidth = displacementMap.width
|
||||
let dispHeight = displacementMap.height
|
||||
let dispBytesPerRow = displacementMap.bytesPerRow
|
||||
let dispBytesPerPixel = displacementMap.bitsPerPixel / 8
|
||||
|
||||
let clampedRadius = min(cornerRadius, min(size.width, size.height) / 2)
|
||||
|
||||
let transform = MeshTransform()
|
||||
var wireframe: CGMutablePath?
|
||||
if generateWireframe {
|
||||
wireframe = CGMutablePath()
|
||||
}
|
||||
|
||||
// Debug flags
|
||||
let debugNoDisplacement = false
|
||||
let debugLogCorner = false
|
||||
|
||||
// Inset the mesh slightly (1 pixel) to clear the clip mask
|
||||
let insetPoints = -1.0
|
||||
let usableWidth = max(1.0, size.width - insetPoints * 2)
|
||||
let usableHeight = max(1.0, size.height - insetPoints * 2)
|
||||
let insetUOffset = insetPoints / size.width
|
||||
let insetVOffset = insetPoints / size.height
|
||||
let usableUNorm = usableWidth / size.width
|
||||
let usableVNorm = usableHeight / size.height
|
||||
|
||||
// Helper to sample displacement and create vertex
|
||||
func makeVertex(u: CGFloat, v: CGFloat, depth: CGFloat = 0) -> (vertex: MeshTransform.Vertex, point: CGPoint) {
|
||||
let mappedU = insetUOffset + u * usableUNorm
|
||||
let mappedV = insetVOffset + v * usableVNorm
|
||||
let fromX: CGFloat
|
||||
let fromY: CGFloat
|
||||
|
||||
if debugNoDisplacement {
|
||||
fromX = mappedU
|
||||
fromY = mappedV
|
||||
} else {
|
||||
let (dispX, dispY) = sampleDisplacement(
|
||||
x: mappedU * CGFloat(dispWidth - 1),
|
||||
y: mappedV * CGFloat(dispHeight - 1),
|
||||
pixels: dispPixels,
|
||||
width: dispWidth,
|
||||
height: dispHeight,
|
||||
bytesPerRow: dispBytesPerRow,
|
||||
bytesPerPixel: dispBytesPerPixel,
|
||||
bezier: bezier
|
||||
)
|
||||
|
||||
// Slight boost near the edge to emphasize the outer strip (rounded-corner aware)
|
||||
let worldX = insetPoints + u * usableWidth
|
||||
let worldY = insetPoints + v * usableHeight
|
||||
let sdf = roundedRectSDF(x: worldX, y: worldY, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let distToEdge = max(0.0, -sdf) // distance inside the rounded rect to the edge
|
||||
let edgeBand = max(0.0, outerEdgeDistance)
|
||||
let edgeBoostGain: CGFloat = 0.5 // up to +50% displacement at the edge, fades inside
|
||||
let edgeBoost: CGFloat
|
||||
if edgeBand > 0 {
|
||||
let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand))
|
||||
let eased = t * t * (3 - 2 * t) // smoothstep
|
||||
edgeBoost = 1.0 + eased * edgeBoostGain
|
||||
} else {
|
||||
edgeBoost = 1.0
|
||||
}
|
||||
|
||||
fromX = max(0.0, min(1.0, mappedU + dispX * displacementMagnitudeU * edgeBoost))
|
||||
fromY = max(0.0, min(1.0, mappedV + dispY * displacementMagnitudeV * edgeBoost))
|
||||
}
|
||||
|
||||
let vertex = MeshTransform.Vertex(from: CGPoint(x: fromX, y: fromY), to: MeshTransform.Point3D(x: mappedU, y: mappedV, z: depth))
|
||||
return (vertex, CGPoint(x: mappedU * size.width, y: mappedV * size.height))
|
||||
}
|
||||
|
||||
var vertexIndex = 0
|
||||
var vertexPoints: [CGPoint] = []
|
||||
|
||||
func addVertex(u: CGFloat, v: CGFloat, depth: CGFloat = 0) -> Int {
|
||||
let (vertex, point) = makeVertex(u: u, v: v, depth: depth)
|
||||
transform.add(vertex)
|
||||
vertexPoints.append(point)
|
||||
let idx = vertexIndex
|
||||
vertexIndex += 1
|
||||
return idx
|
||||
}
|
||||
|
||||
func addVertex(point: CGPoint, depth: CGFloat = 0) -> Int {
|
||||
let u = point.x / size.width
|
||||
let v = point.y / size.height
|
||||
return addVertex(u: u, v: v, depth: depth)
|
||||
}
|
||||
|
||||
func addQuadFace(_ i0: Int, _ i1: Int, _ i2: Int, _ i3: Int) {
|
||||
let p0 = vertexPoints[i0]
|
||||
let p1 = vertexPoints[i1]
|
||||
let p2 = vertexPoints[i2]
|
||||
let p3 = vertexPoints[i3]
|
||||
|
||||
let sdf0 = roundedRectSDF(x: p0.x, y: p0.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf1 = roundedRectSDF(x: p1.x, y: p1.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf2 = roundedRectSDF(x: p2.x, y: p2.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf3 = roundedRectSDF(x: p3.x, y: p3.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
if sdf0 > 0 && sdf1 > 0 && sdf2 > 0 && sdf3 > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
transform.add(MeshTransform.Face(indices: (UInt32(i0), UInt32(i1), UInt32(i2), UInt32(i3)), w: (0.0, 0.0, 0.0, 0.0)))
|
||||
|
||||
if let wireframe {
|
||||
wireframe.move(to: p0)
|
||||
wireframe.addLine(to: p1)
|
||||
wireframe.addLine(to: p2)
|
||||
wireframe.addLine(to: p3)
|
||||
wireframe.closeSubpath()
|
||||
}
|
||||
}
|
||||
|
||||
// Utility to build a grid of vertices from 2D points and emit quads
|
||||
func buildGrid(points: [[CGPoint]]) {
|
||||
guard !points.isEmpty else { return }
|
||||
|
||||
var indexGrid: [[Int]] = []
|
||||
for row in points {
|
||||
var rowIndices: [Int] = []
|
||||
for point in row {
|
||||
rowIndices.append(addVertex(point: point))
|
||||
}
|
||||
indexGrid.append(rowIndices)
|
||||
}
|
||||
|
||||
let numRows = indexGrid.count - 1
|
||||
let numCols = indexGrid.first!.count - 1
|
||||
for row in 0..<numRows {
|
||||
for col in 0..<numCols {
|
||||
addQuadFace(
|
||||
indexGrid[row][col],
|
||||
indexGrid[row][col + 1],
|
||||
indexGrid[row + 1][col + 1],
|
||||
indexGrid[row + 1][col]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let width = size.width
|
||||
let height = size.height
|
||||
|
||||
// Even angular sampling (forced even for collapse), radial sampling mostly even with a thin outer band
|
||||
// (outerEdgeDistance) near the silhouette for edge-specific refraction.
|
||||
let angularStepsBase = max(3, cornerResolution)
|
||||
let angularSteps = angularStepsBase % 2 == 0 ? angularStepsBase : angularStepsBase + 1
|
||||
let radialSteps = max(2, cornerResolution)
|
||||
|
||||
func depthFactorsWithOuterBand(count: Int, band: CGFloat, maxRadius: CGFloat) -> [CGFloat] {
|
||||
guard count > 0, maxRadius > 0 else { return [0, 1] }
|
||||
let bandNorm = max(0, min(1, band / maxRadius))
|
||||
|
||||
// Evenly distribute inner rings up to (1 - bandNorm), then insert the outer strip edge and 1.0.
|
||||
let innerSegments = max(1, count - 1)
|
||||
let innerMax = max(0, 1 - bandNorm)
|
||||
|
||||
var factors: [CGFloat] = (0...innerSegments).map { i in
|
||||
innerMax * CGFloat(i) / CGFloat(innerSegments)
|
||||
}
|
||||
|
||||
func appendUnique(_ value: CGFloat) {
|
||||
if let last = factors.last, abs(last - value) < 1e-4 { return }
|
||||
factors.append(value)
|
||||
}
|
||||
|
||||
appendUnique(innerMax)
|
||||
appendUnique(1.0)
|
||||
|
||||
return factors
|
||||
}
|
||||
|
||||
let depthFactors = depthFactorsWithOuterBand(count: radialSteps, band: outerEdgeDistance, maxRadius: clampedRadius) // 0...1
|
||||
let angularFactors = (0...angularSteps).map { CGFloat($0) / CGFloat(angularSteps) } // 0...1
|
||||
|
||||
// Edge segmentation along the long axes; even spacing
|
||||
let horizontalSegments = max(2, cornerResolution / 2 + 1)
|
||||
let verticalSegments = max(2, cornerResolution / 2 + 1)
|
||||
|
||||
func linearPositions(count: Int, start: CGFloat, end: CGFloat) -> [CGFloat] {
|
||||
return (0...count).map { i in
|
||||
let t = CGFloat(i) / CGFloat(count)
|
||||
return start + (end - start) * t
|
||||
}
|
||||
}
|
||||
|
||||
// Shared tangential coordinates for strips/center
|
||||
let topXPositions: [CGFloat] = linearPositions(
|
||||
count: horizontalSegments,
|
||||
start: clampedRadius,
|
||||
end: width - clampedRadius
|
||||
)
|
||||
let sideYPositions: [CGFloat] = linearPositions(
|
||||
count: verticalSegments,
|
||||
start: clampedRadius,
|
||||
end: height - clampedRadius
|
||||
)
|
||||
|
||||
// Shared depth coordinates (outer -> inner) so seams line up without T-junctions
|
||||
let outerToInner = depthFactors.reversed()
|
||||
let topYPositions: [CGFloat] = outerToInner.map { clampedRadius * (1 - $0) } // 0 ... radius
|
||||
let bottomYPositions: [CGFloat] = depthFactors.map { height - clampedRadius + clampedRadius * $0 } // (h-r) ... h
|
||||
let leftXPositions: [CGFloat] = outerToInner.map { clampedRadius * (1 - $0) } // 0 ... radius
|
||||
let rightXPositions: [CGFloat] = depthFactors.map { width - clampedRadius + clampedRadius * $0 } // (w-r) ... w
|
||||
|
||||
// Corner wedges in polar space with an explicit center fan to avoid zero-area quads
|
||||
func buildCorner(center: CGPoint, startAngle: CGFloat, endAngle: CGFloat) {
|
||||
let ringRadials = outerToInner.filter { $0 > 0 }
|
||||
guard !ringRadials.isEmpty else { return }
|
||||
|
||||
func formatVertex(_ idx: Int) -> String {
|
||||
let p = vertexPoints[idx]
|
||||
return "\(idx)=\(String(format: "(%.2f, %.2f)", p.x, p.y))"
|
||||
}
|
||||
|
||||
// Generate ring vertices from outer arc toward the center point
|
||||
var ringIndices: [[Int]] = []
|
||||
for radial in ringRadials {
|
||||
let r = clampedRadius * radial
|
||||
var row: [Int] = []
|
||||
for t in angularFactors {
|
||||
let angle = startAngle + (endAngle - startAngle) * t
|
||||
let x = center.x + r * cos(angle)
|
||||
let y = center.y + r * sin(angle)
|
||||
row.append(addVertex(point: CGPoint(x: x, y: y)))
|
||||
}
|
||||
ringIndices.append(row)
|
||||
}
|
||||
|
||||
// Quad rings between concentric samples
|
||||
for r in 0..<(ringIndices.count - 1) {
|
||||
let outerRing = ringIndices[r]
|
||||
let innerRing = ringIndices[r + 1]
|
||||
for i in 0..<(outerRing.count - 1) {
|
||||
addQuadFace(
|
||||
outerRing[i],
|
||||
outerRing[i + 1],
|
||||
innerRing[i + 1],
|
||||
innerRing[i]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Final collapse: merge two wedge slices into one quad anchored at the center.
|
||||
// Each quad spans a double-width wedge: center -> v0 -> v1 -> v2 (contiguous along the arc).
|
||||
if let innermostRing = ringIndices.last {
|
||||
let ringSegments = innermostRing.count - 1 // last point is the arc end (not wrapped)
|
||||
guard ringSegments >= 2 else { return }
|
||||
|
||||
if debugLogCorner {
|
||||
let formatted = innermostRing.map { formatVertex($0) }.joined(separator: ", ")
|
||||
print("Corner collapse ringSegments=\(ringSegments) stride=2 angularSteps=\(angularSteps)")
|
||||
print("Innermost ring vertices: \(formatted)")
|
||||
}
|
||||
|
||||
let centerAnchor = addVertex(point: center, depth: -0.02)
|
||||
let stride = 2
|
||||
|
||||
// Each quad covers two arc segments: (vi, vi+1) and (vi+1, vi+2)
|
||||
var i = 0
|
||||
while i + 2 <= ringSegments {
|
||||
let v0 = innermostRing[i]
|
||||
let v1 = innermostRing[i + 1]
|
||||
let v2 = innermostRing[i + 2]
|
||||
|
||||
if debugLogCorner {
|
||||
print("Quad indices: [\(centerAnchor), \(v0), \(v1), \(v2)]")
|
||||
}
|
||||
|
||||
addQuadFace(
|
||||
centerAnchor,
|
||||
v0,
|
||||
v1,
|
||||
v2
|
||||
)
|
||||
|
||||
i += stride
|
||||
}
|
||||
|
||||
// Safety: if an odd segment remains, cap it with a final quad
|
||||
if i < ringSegments {
|
||||
let v0 = innermostRing[ringSegments - 1]
|
||||
let v1 = innermostRing[ringSegments]
|
||||
let v2 = innermostRing[ringSegments] // duplicate to keep quad valid
|
||||
if debugLogCorner {
|
||||
print("Quad indices (odd tail): [\(centerAnchor), \(v0), \(v1), \(v2)]")
|
||||
}
|
||||
addQuadFace(centerAnchor, v0, v1, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edge strips
|
||||
func buildStrip(xPositions: [CGFloat], yPositions: [CGFloat]) {
|
||||
var points: [[CGPoint]] = []
|
||||
for y in yPositions {
|
||||
let row = xPositions.map { CGPoint(x: $0, y: y) }
|
||||
points.append(row)
|
||||
}
|
||||
buildGrid(points: points)
|
||||
}
|
||||
|
||||
// Top / bottom strips
|
||||
buildStrip(xPositions: topXPositions, yPositions: topYPositions)
|
||||
buildStrip(xPositions: topXPositions, yPositions: bottomYPositions)
|
||||
|
||||
// Left / right strips
|
||||
buildStrip(xPositions: leftXPositions, yPositions: sideYPositions)
|
||||
buildStrip(xPositions: rightXPositions, yPositions: sideYPositions)
|
||||
|
||||
// Center patch uses the same tangential sampling to meet edges cleanly
|
||||
buildStrip(xPositions: topXPositions, yPositions: sideYPositions)
|
||||
|
||||
// Corners (angles chosen to keep columns increasing along +x)
|
||||
buildCorner(
|
||||
center: CGPoint(x: clampedRadius, y: clampedRadius),
|
||||
startAngle: .pi,
|
||||
endAngle: 1.5 * .pi
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: width - clampedRadius, y: clampedRadius),
|
||||
startAngle: 1.5 * .pi,
|
||||
endAngle: 2 * .pi
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: width - clampedRadius, y: height - clampedRadius),
|
||||
startAngle: .pi / 2,
|
||||
endAngle: 0
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: clampedRadius, y: height - clampedRadius),
|
||||
startAngle: .pi,
|
||||
endAngle: .pi / 2
|
||||
)
|
||||
|
||||
transform.subdivisionSteps = 0
|
||||
return (mesh: transform, wireframe: wireframe)
|
||||
}
|
||||
|
||||
private let backdropLayerClass: NSObject? = {
|
||||
let name = ("CA" as NSString).appendingFormat("BackdropLayer")
|
||||
if let cls = NSClassFromString(name as String) as AnyObject as? NSObject {
|
||||
|
|
@ -670,11 +103,18 @@ final class LegacyGlassView: UIView {
|
|||
if let backdropLayer = self.backdropLayer {
|
||||
self.layer.addSublayer(backdropLayer)
|
||||
backdropLayer.delegate = self.backdropLayerDelegate
|
||||
invokeBackdropLayerSetScaleMethod(object: backdropLayer, scale: 1.0)
|
||||
backdropLayer.rasterizationScale = 1.0
|
||||
|
||||
let blur: CGFloat
|
||||
let scale: CGFloat
|
||||
|
||||
blur = 2.0
|
||||
scale = 1.0
|
||||
|
||||
invokeBackdropLayerSetScaleMethod(object: backdropLayer, scale: scale)
|
||||
backdropLayer.rasterizationScale = scale
|
||||
|
||||
if let blurFilter = CALayer.blur() {
|
||||
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
|
||||
blurFilter.setValue(blur as NSNumber, forKey: "inputRadius")
|
||||
backdropLayer.filters = [blurFilter]
|
||||
}
|
||||
}
|
||||
|
|
@ -720,7 +160,7 @@ final class LegacyGlassView: UIView {
|
|||
x2: 0.5806878306878306,
|
||||
y2: 0.873015873015873
|
||||
)
|
||||
).mesh.value
|
||||
).mesh.makeValue()
|
||||
|
||||
if let meshTransform {
|
||||
if !transition.animation.isImmediate, let previousTransform = backdropLayer.value(forKey: "meshTransform") as? NSObject {
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ public final class HeaderPanelContainerComponent: Component {
|
|||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
self.clipsToBounds = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
@ -102,7 +100,6 @@ public final class HeaderPanelContainerComponent: Component {
|
|||
self.backgroundContainer.contentView.addSubview(self.backgroundView)
|
||||
self.addSubview(self.backgroundContainer)
|
||||
|
||||
self.contentContainer.clipsToBounds = true
|
||||
self.backgroundView.contentView.addSubview(self.contentContainer)
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +185,7 @@ public final class HeaderPanelContainerComponent: Component {
|
|||
panelView.addSubview(panelComponentView)
|
||||
transition.animateAlpha(view: panelView, from: 0.0, to: 1.0)
|
||||
panelView.separator.opacity = 0.0
|
||||
panelView.clipsToBounds = true
|
||||
if isAnimatingReplacement {
|
||||
panelView.frame = panelFrame
|
||||
} else {
|
||||
|
|
@ -197,7 +195,12 @@ public final class HeaderPanelContainerComponent: Component {
|
|||
|
||||
panelView.separator.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor
|
||||
|
||||
transition.setFrame(view: panelView, frame: panelFrame)
|
||||
let isFrameUpdated = panelComponentView.frame != panelFrame
|
||||
transition.setFrame(view: panelView, frame: panelFrame, completion: { [weak panelView] completed in
|
||||
if let panelView, completed, isFrameUpdated {
|
||||
panelView.clipsToBounds = false
|
||||
}
|
||||
})
|
||||
panelTransition.setFrame(view: panelComponentView, frame: CGRect(origin: CGPoint(), size: panelFrame.size))
|
||||
panelTransition.setFrame(layer: panelView.separator, frame: CGRect(origin: panelFrame.origin, size: CGSize(width: panelFrame.width, height: UIScreenPixel)))
|
||||
|
||||
|
|
@ -219,6 +222,7 @@ public final class HeaderPanelContainerComponent: Component {
|
|||
separator?.removeFromSuperlayer()
|
||||
})
|
||||
if !isAnimatingReplacement {
|
||||
panelView.clipsToBounds = true
|
||||
transition.setFrame(view: panelView, frame: CGRect(origin: panelView.frame.origin, size: CGSize(width: panelView.bounds.width, height: 0.0)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ private final class RestingBackgroundView: UIVisualEffectView {
|
|||
}
|
||||
|
||||
public final class LiquidLensView: UIView {
|
||||
public enum Kind {
|
||||
case externalContainer
|
||||
case builtinContainer
|
||||
case noContainer
|
||||
}
|
||||
|
||||
private struct Params: Equatable {
|
||||
var size: CGSize
|
||||
var selectionX: CGFloat
|
||||
|
|
@ -89,7 +95,7 @@ public final class LiquidLensView: UIView {
|
|||
private let containerView: UIView
|
||||
private let backgroundContainer: GlassBackgroundContainerView?
|
||||
private let genericBackgroundContainer: UIView?
|
||||
private let backgroundView: GlassBackgroundView
|
||||
private let backgroundView: GlassBackgroundView?
|
||||
private var lensView: UIView?
|
||||
private let liftedContainerView: UIView
|
||||
public let contentView: UIView
|
||||
|
|
@ -119,18 +125,23 @@ public final class LiquidLensView: UIView {
|
|||
return self.params?.selectionWidth
|
||||
}
|
||||
|
||||
public init(useBackgroundContainer: Bool = true) {
|
||||
public init(kind: Kind) {
|
||||
self.containerView = UIView()
|
||||
|
||||
if useBackgroundContainer {
|
||||
switch kind {
|
||||
case .builtinContainer:
|
||||
self.backgroundContainer = GlassBackgroundContainerView()
|
||||
self.genericBackgroundContainer = nil
|
||||
} else {
|
||||
case .externalContainer, .noContainer:
|
||||
self.backgroundContainer = nil
|
||||
self.genericBackgroundContainer = UIView()
|
||||
}
|
||||
|
||||
self.backgroundView = GlassBackgroundView()
|
||||
if case .noContainer = kind {
|
||||
self.backgroundView = nil
|
||||
} else {
|
||||
self.backgroundView = GlassBackgroundView()
|
||||
}
|
||||
|
||||
self.contentView = UIView()
|
||||
self.liftedContainerView = UIView()
|
||||
|
|
@ -141,13 +152,19 @@ public final class LiquidLensView: UIView {
|
|||
|
||||
if let backgroundContainer = self.backgroundContainer {
|
||||
self.addSubview(backgroundContainer)
|
||||
backgroundContainer.contentView.addSubview(self.backgroundView)
|
||||
if let backgroundView = self.backgroundView {
|
||||
backgroundContainer.contentView.addSubview(backgroundView)
|
||||
backgroundView.contentView.addSubview(self.containerView)
|
||||
}
|
||||
} else if let genericBackgroundContainer = self.genericBackgroundContainer {
|
||||
self.addSubview(genericBackgroundContainer)
|
||||
genericBackgroundContainer.addSubview(self.backgroundView)
|
||||
if let backgroundView = self.backgroundView {
|
||||
genericBackgroundContainer.addSubview(backgroundView)
|
||||
backgroundView.contentView.addSubview(self.containerView)
|
||||
} else {
|
||||
genericBackgroundContainer.addSubview(self.containerView)
|
||||
}
|
||||
}
|
||||
|
||||
self.backgroundView.contentView.addSubview(self.containerView)
|
||||
self.containerView.isUserInteractionEnabled = false
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
|
|
@ -168,7 +185,10 @@ public final class LiquidLensView: UIView {
|
|||
}
|
||||
lensView.layer.zPosition = 10.0
|
||||
|
||||
self.liftedContainerView.addSubview(self.restingBackgroundView)
|
||||
if case .noContainer = kind {
|
||||
} else {
|
||||
self.liftedContainerView.addSubview(self.restingBackgroundView)
|
||||
}
|
||||
|
||||
self.containerView.addSubview(self.liftedContainerView)
|
||||
self.containerView.addSubview(lensView)
|
||||
|
|
@ -179,8 +199,11 @@ public final class LiquidLensView: UIView {
|
|||
} else if let genericBackgroundContainer = self.genericBackgroundContainer {
|
||||
lensView.perform(NSSelectorFromString("setLiftedContainerView:"), with: genericBackgroundContainer)
|
||||
}
|
||||
lensView.perform(NSSelectorFromString("setLiftedContentView:"), with: self.liftedContainerView)
|
||||
lensView.perform(NSSelectorFromString("setOverridePunchoutView:"), with: self.contentView)
|
||||
if case .noContainer = kind {
|
||||
} else {
|
||||
lensView.perform(NSSelectorFromString("setLiftedContentView:"), with: self.liftedContainerView)
|
||||
lensView.perform(NSSelectorFromString("setOverridePunchoutView:"), with: self.contentView)
|
||||
}
|
||||
|
||||
do {
|
||||
let selector = NSSelectorFromString("setLiftedContentMode:")
|
||||
|
|
@ -211,9 +234,16 @@ public final class LiquidLensView: UIView {
|
|||
|
||||
lensView.setValue(UIColor(white: 0.0, alpha: 0.1), forKey: "restingBackgroundColor")
|
||||
} else {
|
||||
let legacySelectionView = GlassBackgroundView.ContentImageView()
|
||||
self.legacySelectionView = legacySelectionView
|
||||
self.backgroundView.contentView.insertSubview(legacySelectionView, at: 0)
|
||||
if case .noContainer = kind {
|
||||
} else {
|
||||
let legacySelectionView = GlassBackgroundView.ContentImageView()
|
||||
self.legacySelectionView = legacySelectionView
|
||||
if let backgroundView = self.backgroundView {
|
||||
backgroundView.contentView.insertSubview(legacySelectionView, at: 0)
|
||||
} else {
|
||||
self.containerView.insertSubview(legacySelectionView, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
let legacyContentMaskView = UIView()
|
||||
legacyContentMaskView.backgroundColor = .white
|
||||
|
|
@ -346,8 +376,10 @@ public final class LiquidLensView: UIView {
|
|||
transition.setFrame(view: genericBackgroundContainer, frame: CGRect(origin: CGPoint(), size: params.size))
|
||||
}
|
||||
|
||||
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: params.size))
|
||||
self.backgroundView.update(size: params.size, cornerRadius: params.size.height * 0.5, isDark: params.isDark, tintColor: GlassBackgroundView.TintColor.init(kind: .panel, color: UIColor(white: params.isDark ? 0.0 : 1.0, alpha: 0.6)), isInteractive: true, transition: transition)
|
||||
if let backgroundView = self.backgroundView {
|
||||
transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: params.size))
|
||||
backgroundView.update(size: params.size, cornerRadius: params.size.height * 0.5, isDark: params.isDark, tintColor: GlassBackgroundView.TintColor.init(kind: .panel, color: UIColor(white: params.isDark ? 0.0 : 1.0, alpha: 0.6)), isInteractive: true, transition: transition)
|
||||
}
|
||||
|
||||
if self.contentView.bounds.size != params.size {
|
||||
self.contentView.clipsToBounds = true
|
||||
|
|
@ -369,7 +401,7 @@ public final class LiquidLensView: UIView {
|
|||
transition.setCornerRadius(layer: self.liftedContainerView.layer, cornerRadius: params.size.height * 0.5)
|
||||
}
|
||||
|
||||
let baseLensFrame = CGRect(origin: CGPoint(x: max(0.0, min(params.selectionX, params.size.width - params.selectionWidth)), y: 0.0), size: CGSize(width: params.selectionWidth, height: params.size.height))
|
||||
let baseLensFrame = CGRect(origin: CGPoint(x: params.selectionX, y: 0.0), size: CGSize(width: params.selectionWidth, height: params.size.height))
|
||||
self.updateLens(params: LensParams(baseFrame: baseLensFrame, isLifted: params.isLifted), animated: !transition.animation.isImmediate)
|
||||
|
||||
if let legacyContentMaskView = self.legacyContentMaskView {
|
||||
|
|
|
|||
|
|
@ -20,5 +20,16 @@ typedef struct MeshTransformMeshVertex {
|
|||
MeshTransformPoint3D to;
|
||||
} MeshTransformMeshVertex;
|
||||
|
||||
@protocol MeshTransformClass <NSObject>
|
||||
|
||||
- (id)meshTransformWithVertexCount:(NSUInteger)vertexCount
|
||||
vertices:(MeshTransformMeshVertex *)vertices
|
||||
faceCount:(NSUInteger)faceCount
|
||||
faces:(MeshTransformMeshFace *)faces
|
||||
depthNormalization:(NSString *)depthNormalization;
|
||||
|
||||
@end
|
||||
|
||||
extern NSString * const kCADepthNormalizationNone;
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -0,0 +1,628 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
|
||||
private func a(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat
|
||||
{
|
||||
return 1.0 - 3.0 * a2 + 3.0 * a1
|
||||
}
|
||||
|
||||
private func b(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat
|
||||
{
|
||||
return 3.0 * a2 - 6.0 * a1
|
||||
}
|
||||
|
||||
private func c(_ a1: CGFloat) -> CGFloat
|
||||
{
|
||||
return 3.0 * a1
|
||||
}
|
||||
|
||||
private func calcBezier(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat
|
||||
{
|
||||
return ((a(a1, a2)*t + b(a1, a2))*t + c(a1)) * t
|
||||
}
|
||||
|
||||
private func calcSlope(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat
|
||||
{
|
||||
return 3.0 * a(a1, a2) * t * t + 2.0 * b(a1, a2) * t + c(a1)
|
||||
}
|
||||
|
||||
private func getTForX(_ x: CGFloat, _ x1: CGFloat, _ x2: CGFloat) -> CGFloat {
|
||||
var t = x
|
||||
var i = 0
|
||||
while i < 4 {
|
||||
let currentSlope = calcSlope(t, x1, x2)
|
||||
if currentSlope == 0.0 {
|
||||
return t
|
||||
} else {
|
||||
let currentX = calcBezier(t, x1, x2) - x
|
||||
t -= currentX / currentSlope
|
||||
}
|
||||
|
||||
i += 1
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
private func bezierPoint(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat) -> CGFloat
|
||||
{
|
||||
var value = calcBezier(getTForX(x, x1, x2), y1, y2)
|
||||
if value >= 0.997 {
|
||||
value = 1.0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Bezier control points for displacement easing curve
|
||||
public struct DisplacementBezier {
|
||||
var x1: CGFloat
|
||||
var y1: CGFloat
|
||||
var x2: CGFloat
|
||||
var y2: CGFloat
|
||||
|
||||
public init(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat) {
|
||||
self.x1 = x1
|
||||
self.y1 = y1
|
||||
self.x2 = x2
|
||||
self.y2 = y2
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes signed distance from a point to the edge of a rounded rectangle.
|
||||
/// Returns negative inside, zero on edge, positive outside.
|
||||
/// All values in points.
|
||||
public func roundedRectSDF(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
|
||||
// Center the point (SDF formula assumes center at origin)
|
||||
let px = x - width / 2
|
||||
let py = y - height / 2
|
||||
|
||||
// Half extents of the box
|
||||
let bx = width / 2
|
||||
let by = height / 2
|
||||
|
||||
// Standard rounded box SDF (Inigo Quilez formula)
|
||||
let qx = abs(px) - bx + cornerRadius
|
||||
let qy = abs(py) - by + cornerRadius
|
||||
|
||||
let outsideDist = hypot(max(qx, 0), max(qy, 0))
|
||||
let insideDist = min(max(qx, qy), 0)
|
||||
|
||||
return outsideDist + insideDist - cornerRadius
|
||||
}
|
||||
|
||||
/// Computes the gradient (outward normal) of the rounded rect SDF.
|
||||
/// Returns normalized direction perpendicular to the nearest edge point.
|
||||
public func roundedRectGradient(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> (nx: CGFloat, ny: CGFloat) {
|
||||
// Center the point
|
||||
let px = x - width / 2
|
||||
let py = y - height / 2
|
||||
|
||||
// Half extents
|
||||
let bx = width / 2
|
||||
let by = height / 2
|
||||
|
||||
// q values from SDF formula
|
||||
let qx = abs(px) - bx + cornerRadius
|
||||
let qy = abs(py) - by + cornerRadius
|
||||
|
||||
var nx: CGFloat = 0
|
||||
var ny: CGFloat = 0
|
||||
|
||||
if qx > 0 && qy > 0 {
|
||||
// Corner region - normal points radially from corner arc center
|
||||
let d = hypot(qx, qy)
|
||||
if d > 0 {
|
||||
nx = qx / d
|
||||
ny = qy / d
|
||||
}
|
||||
} else if qx > qy {
|
||||
// Nearest point is on vertical edge (left or right)
|
||||
nx = 1
|
||||
ny = 0
|
||||
} else {
|
||||
// Nearest point is on horizontal edge (top or bottom)
|
||||
nx = 0
|
||||
ny = 1
|
||||
}
|
||||
|
||||
// Restore sign based on which side of center we're on
|
||||
if px < 0 { nx = -nx }
|
||||
if py < 0 { ny = -ny }
|
||||
|
||||
return (nx, ny)
|
||||
}
|
||||
|
||||
/// Generates a displacement map image as a signed distance field from rounded rect edges.
|
||||
/// - edgeDistance: The distance (in points) over which displacement is applied
|
||||
/// - R channel: X displacement (127 = neutral, 0 = max left, 255 = max right)
|
||||
/// - G channel: Y displacement (127 = neutral, 0 = max up, 255 = max down)
|
||||
/// - B channel: Unused (always 0)
|
||||
/// Displacement is maximum at the edge and fades linearly to zero at edgeDistance.
|
||||
/// Actual displacement magnitude is applied when sampling the map.
|
||||
public func generateDisplacementMap(size: CGSize, cornerRadius: CGFloat, edgeDistance: CGFloat, scale: CGFloat) -> CGImage? {
|
||||
let width = Int(size.width * scale)
|
||||
let height = Int(size.height * scale)
|
||||
|
||||
// Clamp corner radius
|
||||
let maxCornerRadius = min(size.width, size.height) / 2.0
|
||||
let clampedRadius = min(cornerRadius, maxCornerRadius)
|
||||
|
||||
// Create bitmap context
|
||||
var pixelData = [UInt8](repeating: 0, count: width * height * 4)
|
||||
|
||||
for py in 0 ..< height {
|
||||
for px in 0 ..< width {
|
||||
// Convert pixel to point coordinates
|
||||
let x = CGFloat(px) / scale
|
||||
let y = CGFloat(py) / scale
|
||||
|
||||
// Get signed distance (negative inside, positive outside)
|
||||
let sdf = roundedRectSDF(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
// Get gradient (outward normal direction)
|
||||
let (nx, ny) = roundedRectGradient(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
// Inward normal (content moves away from edge, toward center)
|
||||
let inwardX = -nx
|
||||
let inwardY = -ny
|
||||
|
||||
// Distance from edge (positive inside the shape)
|
||||
let distFromEdge = -sdf
|
||||
|
||||
// Weight: 1 at edge, 0 at edgeDistance (linear falloff)
|
||||
let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance))
|
||||
|
||||
// Displacement modulated by distance from edge
|
||||
let displacementX = inwardX * weight
|
||||
let displacementY = inwardY * weight
|
||||
|
||||
// Encode in R/G: 127 = neutral, map -1..1 to 0..254
|
||||
let r = UInt8(max(0, min(255, Int(127 + displacementX * 127))))
|
||||
let g = UInt8(max(0, min(255, Int(127 + displacementY * 127))))
|
||||
|
||||
let idx = (py * width + px) * 4
|
||||
pixelData[idx + 0] = r // X displacement
|
||||
pixelData[idx + 1] = g // Y displacement
|
||||
pixelData[idx + 2] = 0 // Unused
|
||||
pixelData[idx + 3] = 255 // A
|
||||
}
|
||||
}
|
||||
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: &pixelData,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
/// Samples displacement from a displacement map with bilinear interpolation and bezier easing
|
||||
/// - Parameters:
|
||||
/// - x, y: Coordinates in the displacement map's pixel space
|
||||
/// - pixels: Pointer to displacement map pixel data
|
||||
/// - width, height: Displacement map dimensions
|
||||
/// - bytesPerRow, bytesPerPixel: Displacement map layout
|
||||
/// - bezier: Bezier control points for easing curve
|
||||
/// - Returns: Displacement (dx, dy) in range -1..1 with bezier easing applied
|
||||
public func sampleDisplacement(
|
||||
x: CGFloat,
|
||||
y: CGFloat,
|
||||
pixels: UnsafePointer<UInt8>,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bytesPerRow: Int,
|
||||
bytesPerPixel: Int,
|
||||
bezier: DisplacementBezier
|
||||
) -> (dx: CGFloat, dy: CGFloat) {
|
||||
let clampedX = max(0, min(CGFloat(width - 1), x))
|
||||
let clampedY = max(0, min(CGFloat(height - 1), y))
|
||||
|
||||
let x0 = Int(clampedX)
|
||||
let y0 = Int(clampedY)
|
||||
let x1 = min(x0 + 1, width - 1)
|
||||
let y1 = min(y0 + 1, height - 1)
|
||||
|
||||
let fx = clampedX - CGFloat(x0)
|
||||
let fy = clampedY - CGFloat(y0)
|
||||
|
||||
func sample(_ sx: Int, _ sy: Int) -> (r: CGFloat, g: CGFloat) {
|
||||
let offset = sy * bytesPerRow + sx * bytesPerPixel
|
||||
return (CGFloat(pixels[offset + 0]), CGFloat(pixels[offset + 1]))
|
||||
}
|
||||
|
||||
let c00 = sample(x0, y0)
|
||||
let c10 = sample(x1, y0)
|
||||
let c01 = sample(x0, y1)
|
||||
let c11 = sample(x1, y1)
|
||||
|
||||
let r = (c00.r * (1 - fx) + c10.r * fx) * (1 - fy) + (c01.r * (1 - fx) + c11.r * fx) * fy
|
||||
let g = (c00.g * (1 - fx) + c10.g * fx) * (1 - fy) + (c01.g * (1 - fx) + c11.g * fx) * fy
|
||||
|
||||
// Decode: 127 = neutral, map 0..254 to -1..1
|
||||
var dx = (r - 127.0) / 127.0
|
||||
var dy = (g - 127.0) / 127.0
|
||||
|
||||
// Apply bezier easing to vector magnitude, preserving direction
|
||||
let mag = hypot(dx, dy)
|
||||
if mag > 0 {
|
||||
let newMag = bezierPoint(bezier.x1, bezier.y1, bezier.x2, bezier.y2, mag)
|
||||
let scale = newMag / mag
|
||||
dx *= scale
|
||||
dy *= scale
|
||||
}
|
||||
|
||||
return (dx, dy)
|
||||
}
|
||||
|
||||
/// Generates a glass mesh with corner-aware topology.
|
||||
/// - 4 radial corner wedges sampled in polar space
|
||||
/// - 4 edge strips aligned with the rectangle sides
|
||||
/// - 1 center patch
|
||||
/// Corner/edge seams share the same coordinates (but do not reuse vertices) so
|
||||
/// the neighbouring faces fit perfectly without T-junctions.
|
||||
public func generateGlassMeshFromDisplacementMap(
|
||||
size: CGSize,
|
||||
cornerRadius: CGFloat,
|
||||
displacementMap: CGImage,
|
||||
displacementMagnitudeU: CGFloat,
|
||||
displacementMagnitudeV: CGFloat,
|
||||
cornerResolution: Int,
|
||||
outerEdgeDistance: CGFloat,
|
||||
bezier: DisplacementBezier,
|
||||
generateWireframe: Bool = false
|
||||
) -> (mesh: MeshTransform, wireframe: CGPath?) {
|
||||
guard let dispDataProvider = displacementMap.dataProvider,
|
||||
let dispData = dispDataProvider.data,
|
||||
let dispPixels = CFDataGetBytePtr(dispData) else {
|
||||
return (mesh: MeshTransform(), wireframe: nil)
|
||||
}
|
||||
|
||||
let dispWidth = displacementMap.width
|
||||
let dispHeight = displacementMap.height
|
||||
let dispBytesPerRow = displacementMap.bytesPerRow
|
||||
let dispBytesPerPixel = displacementMap.bitsPerPixel / 8
|
||||
|
||||
let clampedRadius = min(cornerRadius, min(size.width, size.height) / 2)
|
||||
|
||||
let transform = MeshTransform()
|
||||
var wireframe: CGMutablePath?
|
||||
if generateWireframe {
|
||||
wireframe = CGMutablePath()
|
||||
}
|
||||
|
||||
// Debug flags
|
||||
let debugNoDisplacement = false
|
||||
let debugLogCorner = false
|
||||
|
||||
// Inset the mesh slightly (1 pixel) to clear the clip mask
|
||||
let insetPoints = -1.0
|
||||
let usableWidth = max(1.0, size.width - insetPoints * 2)
|
||||
let usableHeight = max(1.0, size.height - insetPoints * 2)
|
||||
let insetUOffset = insetPoints / size.width
|
||||
let insetVOffset = insetPoints / size.height
|
||||
let usableUNorm = usableWidth / size.width
|
||||
let usableVNorm = usableHeight / size.height
|
||||
|
||||
// Helper to sample displacement and create vertex
|
||||
func makeVertex(u: CGFloat, v: CGFloat, depth: CGFloat = 0) -> (vertex: MeshTransform.Vertex, point: CGPoint) {
|
||||
let mappedU = insetUOffset + u * usableUNorm
|
||||
let mappedV = insetVOffset + v * usableVNorm
|
||||
let fromX: CGFloat
|
||||
let fromY: CGFloat
|
||||
|
||||
if debugNoDisplacement {
|
||||
fromX = mappedU
|
||||
fromY = mappedV
|
||||
} else {
|
||||
let (dispX, dispY) = sampleDisplacement(
|
||||
x: mappedU * CGFloat(dispWidth - 1),
|
||||
y: mappedV * CGFloat(dispHeight - 1),
|
||||
pixels: dispPixels,
|
||||
width: dispWidth,
|
||||
height: dispHeight,
|
||||
bytesPerRow: dispBytesPerRow,
|
||||
bytesPerPixel: dispBytesPerPixel,
|
||||
bezier: bezier
|
||||
)
|
||||
|
||||
// Slight boost near the edge to emphasize the outer strip (rounded-corner aware)
|
||||
let worldX = insetPoints + u * usableWidth
|
||||
let worldY = insetPoints + v * usableHeight
|
||||
let sdf = roundedRectSDF(x: worldX, y: worldY, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let distToEdge = max(0.0, -sdf) // distance inside the rounded rect to the edge
|
||||
let edgeBand = max(0.0, outerEdgeDistance)
|
||||
let edgeBoostGain: CGFloat = 0.5 // up to +50% displacement at the edge, fades inside
|
||||
let edgeBoost: CGFloat
|
||||
if edgeBand > 0 {
|
||||
let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand))
|
||||
let eased = t * t * (3 - 2 * t) // smoothstep
|
||||
edgeBoost = 1.0 + eased * edgeBoostGain
|
||||
} else {
|
||||
edgeBoost = 1.0
|
||||
}
|
||||
|
||||
fromX = max(0.0, min(1.0, mappedU + dispX * displacementMagnitudeU * edgeBoost))
|
||||
fromY = max(0.0, min(1.0, mappedV + dispY * displacementMagnitudeV * edgeBoost))
|
||||
}
|
||||
|
||||
let vertex = MeshTransform.Vertex(from: CGPoint(x: fromX, y: fromY), to: MeshTransform.Point3D(x: mappedU, y: mappedV, z: depth))
|
||||
return (vertex, CGPoint(x: mappedU * size.width, y: mappedV * size.height))
|
||||
}
|
||||
|
||||
var vertexIndex = 0
|
||||
var vertexPoints: [CGPoint] = []
|
||||
|
||||
func addVertex(u: CGFloat, v: CGFloat, depth: CGFloat = 0) -> Int {
|
||||
let (vertex, point) = makeVertex(u: u, v: v, depth: depth)
|
||||
transform.add(vertex)
|
||||
vertexPoints.append(point)
|
||||
let idx = vertexIndex
|
||||
vertexIndex += 1
|
||||
return idx
|
||||
}
|
||||
|
||||
func addVertex(point: CGPoint, depth: CGFloat = 0) -> Int {
|
||||
let u = point.x / size.width
|
||||
let v = point.y / size.height
|
||||
return addVertex(u: u, v: v, depth: depth)
|
||||
}
|
||||
|
||||
func addQuadFace(_ i0: Int, _ i1: Int, _ i2: Int, _ i3: Int) {
|
||||
let p0 = vertexPoints[i0]
|
||||
let p1 = vertexPoints[i1]
|
||||
let p2 = vertexPoints[i2]
|
||||
let p3 = vertexPoints[i3]
|
||||
|
||||
let sdf0 = roundedRectSDF(x: p0.x, y: p0.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf1 = roundedRectSDF(x: p1.x, y: p1.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf2 = roundedRectSDF(x: p2.x, y: p2.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
let sdf3 = roundedRectSDF(x: p3.x, y: p3.y, width: size.width, height: size.height, cornerRadius: clampedRadius)
|
||||
|
||||
if sdf0 > 0 && sdf1 > 0 && sdf2 > 0 && sdf3 > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
transform.add(MeshTransform.Face(indices: (UInt32(i0), UInt32(i1), UInt32(i2), UInt32(i3)), w: (0.0, 0.0, 0.0, 0.0)))
|
||||
|
||||
if let wireframe {
|
||||
wireframe.move(to: p0)
|
||||
wireframe.addLine(to: p1)
|
||||
wireframe.addLine(to: p2)
|
||||
wireframe.addLine(to: p3)
|
||||
wireframe.closeSubpath()
|
||||
}
|
||||
}
|
||||
|
||||
// Utility to build a grid of vertices from 2D points and emit quads
|
||||
func buildGrid(points: [[CGPoint]]) {
|
||||
guard !points.isEmpty else { return }
|
||||
|
||||
var indexGrid: [[Int]] = []
|
||||
for row in points {
|
||||
var rowIndices: [Int] = []
|
||||
for point in row {
|
||||
rowIndices.append(addVertex(point: point))
|
||||
}
|
||||
indexGrid.append(rowIndices)
|
||||
}
|
||||
|
||||
let numRows = indexGrid.count - 1
|
||||
let numCols = indexGrid.first!.count - 1
|
||||
for row in 0..<numRows {
|
||||
for col in 0..<numCols {
|
||||
addQuadFace(
|
||||
indexGrid[row][col],
|
||||
indexGrid[row][col + 1],
|
||||
indexGrid[row + 1][col + 1],
|
||||
indexGrid[row + 1][col]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let width = size.width
|
||||
let height = size.height
|
||||
|
||||
// Even angular sampling (forced even for collapse), radial sampling mostly even with a thin outer band
|
||||
// (outerEdgeDistance) near the silhouette for edge-specific refraction.
|
||||
let angularStepsBase = max(3, cornerResolution)
|
||||
let angularSteps = angularStepsBase % 2 == 0 ? angularStepsBase : angularStepsBase + 1
|
||||
let radialSteps = max(2, cornerResolution)
|
||||
|
||||
func depthFactorsWithOuterBand(count: Int, band: CGFloat, maxRadius: CGFloat) -> [CGFloat] {
|
||||
guard count > 0, maxRadius > 0 else { return [0, 1] }
|
||||
let bandNorm = max(0, min(1, band / maxRadius))
|
||||
|
||||
// Evenly distribute inner rings up to (1 - bandNorm), then insert the outer strip edge and 1.0.
|
||||
let innerSegments = max(1, count - 1)
|
||||
let innerMax = max(0, 1 - bandNorm)
|
||||
|
||||
var factors: [CGFloat] = (0...innerSegments).map { i in
|
||||
innerMax * CGFloat(i) / CGFloat(innerSegments)
|
||||
}
|
||||
|
||||
func appendUnique(_ value: CGFloat) {
|
||||
if let last = factors.last, abs(last - value) < 1e-4 { return }
|
||||
factors.append(value)
|
||||
}
|
||||
|
||||
appendUnique(innerMax)
|
||||
appendUnique(1.0)
|
||||
|
||||
return factors
|
||||
}
|
||||
|
||||
let depthFactors = depthFactorsWithOuterBand(count: radialSteps, band: outerEdgeDistance, maxRadius: clampedRadius) // 0...1
|
||||
let angularFactors = (0...angularSteps).map { CGFloat($0) / CGFloat(angularSteps) } // 0...1
|
||||
|
||||
// Edge segmentation along the long axes; even spacing
|
||||
let horizontalSegments = max(2, cornerResolution / 2 + 1)
|
||||
let verticalSegments = max(2, cornerResolution / 2 + 1)
|
||||
|
||||
func linearPositions(count: Int, start: CGFloat, end: CGFloat) -> [CGFloat] {
|
||||
return (0...count).map { i in
|
||||
let t = CGFloat(i) / CGFloat(count)
|
||||
return start + (end - start) * t
|
||||
}
|
||||
}
|
||||
|
||||
// Shared tangential coordinates for strips/center
|
||||
let topXPositions: [CGFloat] = linearPositions(
|
||||
count: horizontalSegments,
|
||||
start: clampedRadius,
|
||||
end: width - clampedRadius
|
||||
)
|
||||
let sideYPositions: [CGFloat] = linearPositions(
|
||||
count: verticalSegments,
|
||||
start: clampedRadius,
|
||||
end: height - clampedRadius
|
||||
)
|
||||
|
||||
// Shared depth coordinates (outer -> inner) so seams line up without T-junctions
|
||||
let outerToInner = depthFactors.reversed()
|
||||
let topYPositions: [CGFloat] = outerToInner.map { clampedRadius * (1 - $0) } // 0 ... radius
|
||||
let bottomYPositions: [CGFloat] = depthFactors.map { height - clampedRadius + clampedRadius * $0 } // (h-r) ... h
|
||||
let leftXPositions: [CGFloat] = outerToInner.map { clampedRadius * (1 - $0) } // 0 ... radius
|
||||
let rightXPositions: [CGFloat] = depthFactors.map { width - clampedRadius + clampedRadius * $0 } // (w-r) ... w
|
||||
|
||||
// Corner wedges in polar space with an explicit center fan to avoid zero-area quads
|
||||
func buildCorner(center: CGPoint, startAngle: CGFloat, endAngle: CGFloat) {
|
||||
let ringRadials = outerToInner.filter { $0 > 0 }
|
||||
guard !ringRadials.isEmpty else { return }
|
||||
|
||||
func formatVertex(_ idx: Int) -> String {
|
||||
let p = vertexPoints[idx]
|
||||
return "\(idx)=\(String(format: "(%.2f, %.2f)", p.x, p.y))"
|
||||
}
|
||||
|
||||
// Generate ring vertices from outer arc toward the center point
|
||||
var ringIndices: [[Int]] = []
|
||||
for radial in ringRadials {
|
||||
let r = clampedRadius * radial
|
||||
var row: [Int] = []
|
||||
for t in angularFactors {
|
||||
let angle = startAngle + (endAngle - startAngle) * t
|
||||
let x = center.x + r * cos(angle)
|
||||
let y = center.y + r * sin(angle)
|
||||
row.append(addVertex(point: CGPoint(x: x, y: y)))
|
||||
}
|
||||
ringIndices.append(row)
|
||||
}
|
||||
|
||||
// Quad rings between concentric samples
|
||||
for r in 0..<(ringIndices.count - 1) {
|
||||
let outerRing = ringIndices[r]
|
||||
let innerRing = ringIndices[r + 1]
|
||||
for i in 0..<(outerRing.count - 1) {
|
||||
addQuadFace(
|
||||
outerRing[i],
|
||||
outerRing[i + 1],
|
||||
innerRing[i + 1],
|
||||
innerRing[i]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Final collapse: merge two wedge slices into one quad anchored at the center.
|
||||
// Each quad spans a double-width wedge: center -> v0 -> v1 -> v2 (contiguous along the arc).
|
||||
if let innermostRing = ringIndices.last {
|
||||
let ringSegments = innermostRing.count - 1 // last point is the arc end (not wrapped)
|
||||
guard ringSegments >= 2 else { return }
|
||||
|
||||
if debugLogCorner {
|
||||
let formatted = innermostRing.map { formatVertex($0) }.joined(separator: ", ")
|
||||
print("Corner collapse ringSegments=\(ringSegments) stride=2 angularSteps=\(angularSteps)")
|
||||
print("Innermost ring vertices: \(formatted)")
|
||||
}
|
||||
|
||||
let centerAnchor = addVertex(point: center, depth: -0.02)
|
||||
let stride = 2
|
||||
|
||||
// Each quad covers two arc segments: (vi, vi+1) and (vi+1, vi+2)
|
||||
var i = 0
|
||||
while i + 2 <= ringSegments {
|
||||
let v0 = innermostRing[i]
|
||||
let v1 = innermostRing[i + 1]
|
||||
let v2 = innermostRing[i + 2]
|
||||
|
||||
if debugLogCorner {
|
||||
print("Quad indices: [\(centerAnchor), \(v0), \(v1), \(v2)]")
|
||||
}
|
||||
|
||||
addQuadFace(
|
||||
centerAnchor,
|
||||
v0,
|
||||
v1,
|
||||
v2
|
||||
)
|
||||
|
||||
i += stride
|
||||
}
|
||||
|
||||
// Safety: if an odd segment remains, cap it with a final quad
|
||||
if i < ringSegments {
|
||||
let v0 = innermostRing[ringSegments - 1]
|
||||
let v1 = innermostRing[ringSegments]
|
||||
let v2 = innermostRing[ringSegments] // duplicate to keep quad valid
|
||||
if debugLogCorner {
|
||||
print("Quad indices (odd tail): [\(centerAnchor), \(v0), \(v1), \(v2)]")
|
||||
}
|
||||
addQuadFace(centerAnchor, v0, v1, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edge strips
|
||||
func buildStrip(xPositions: [CGFloat], yPositions: [CGFloat]) {
|
||||
var points: [[CGPoint]] = []
|
||||
for y in yPositions {
|
||||
let row = xPositions.map { CGPoint(x: $0, y: y) }
|
||||
points.append(row)
|
||||
}
|
||||
buildGrid(points: points)
|
||||
}
|
||||
|
||||
// Top / bottom strips
|
||||
buildStrip(xPositions: topXPositions, yPositions: topYPositions)
|
||||
buildStrip(xPositions: topXPositions, yPositions: bottomYPositions)
|
||||
|
||||
// Left / right strips
|
||||
buildStrip(xPositions: leftXPositions, yPositions: sideYPositions)
|
||||
buildStrip(xPositions: rightXPositions, yPositions: sideYPositions)
|
||||
|
||||
// Center patch uses the same tangential sampling to meet edges cleanly
|
||||
buildStrip(xPositions: topXPositions, yPositions: sideYPositions)
|
||||
|
||||
// Corners (angles chosen to keep columns increasing along +x)
|
||||
buildCorner(
|
||||
center: CGPoint(x: clampedRadius, y: clampedRadius),
|
||||
startAngle: .pi,
|
||||
endAngle: 1.5 * .pi
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: width - clampedRadius, y: clampedRadius),
|
||||
startAngle: 1.5 * .pi,
|
||||
endAngle: 2 * .pi
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: width - clampedRadius, y: height - clampedRadius),
|
||||
startAngle: .pi / 2,
|
||||
endAngle: 0
|
||||
)
|
||||
buildCorner(
|
||||
center: CGPoint(x: clampedRadius, y: height - clampedRadius),
|
||||
startAngle: .pi,
|
||||
endAngle: .pi / 2
|
||||
)
|
||||
|
||||
transform.subdivisionSteps = 0
|
||||
return (mesh: transform, wireframe: wireframe)
|
||||
}
|
||||
|
|
@ -10,6 +10,14 @@ private let transformClass: NSObject? = {
|
|||
return nil
|
||||
}()
|
||||
|
||||
private let immutableTransformClass: NSObject? = {
|
||||
let name = ("CA" as NSString).appendingFormat("MeshTransform")
|
||||
if let cls = NSClassFromString(name as String) as AnyObject as? NSObject {
|
||||
return cls
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
@inline(__always)
|
||||
private func getMethod<T>(object: NSObject, selector: String) -> T? {
|
||||
guard let method = object.method(for: NSSelectorFromString(selector)) else {
|
||||
|
|
@ -80,33 +88,55 @@ private func invokeTransformSetSubdivisionStepsMethod(object: NSObject, value: I
|
|||
}
|
||||
|
||||
public final class MeshTransform {
|
||||
public typealias Value = NSObject
|
||||
|
||||
public typealias Point3D = MeshTransformPoint3D
|
||||
public typealias Vertex = MeshTransformMeshVertex
|
||||
public typealias Face = MeshTransformMeshFace
|
||||
|
||||
public let value: NSObject?
|
||||
private var vertices: ContiguousArray<Vertex> = []
|
||||
private var faces: ContiguousArray<Face> = []
|
||||
|
||||
public var subdivisionSteps: Int = -1 {
|
||||
didSet {
|
||||
if self.subdivisionSteps != oldValue, let value {
|
||||
/*if self.subdivisionSteps != oldValue, let value {
|
||||
invokeTransformSetSubdivisionStepsMethod(object: value, value: self.subdivisionSteps)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
public init() {
|
||||
self.value = invokeTransformCreateMethod()
|
||||
}
|
||||
|
||||
public func add(_ vertex: Vertex) {
|
||||
if let value = self.value {
|
||||
/*if let value = self.value {
|
||||
invokeTransformAddVertexMethod(object: value, vertex: vertex)
|
||||
}
|
||||
}*/
|
||||
self.vertices.append(vertex)
|
||||
}
|
||||
|
||||
public func add(_ face: Face) {
|
||||
if let value = self.value {
|
||||
/*if let value = self.value {
|
||||
invokeTransformAddFaceMethod(object: value, face: face)
|
||||
}*/
|
||||
self.faces.append(face)
|
||||
}
|
||||
|
||||
public consuming func makeValue() -> Value? {
|
||||
guard let transformClass else {
|
||||
return nil
|
||||
}
|
||||
let value = unsafeBitCast(transformClass, to: MeshTransformClass.self)
|
||||
let result = self.vertices.withUnsafeMutableBufferPointer { vertices -> NSObject? in
|
||||
return self.faces.withUnsafeMutableBufferPointer { faces -> NSObject? in
|
||||
let result = value.meshTransform(withVertexCount: UInt(vertices.count), vertices: vertices.baseAddress!, faceCount: UInt(faces.count), faces: faces.baseAddress!, depthNormalization: kCADepthNormalizationNone)
|
||||
return result as? NSObject
|
||||
}
|
||||
}
|
||||
if let result {
|
||||
invokeTransformSetSubdivisionStepsMethod(object: result, value: self.subdivisionSteps)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -548,6 +548,8 @@ public final class NavigationBarImpl: ASDisplayNode, NavigationBar {
|
|||
}
|
||||
}
|
||||
|
||||
public var customOverBackgroundContentSubview: UIView?
|
||||
|
||||
public init(presentationData: NavigationBarPresentationData) {
|
||||
self.presentationData = presentationData
|
||||
self.stripeNode = ASDisplayNode()
|
||||
|
|
|
|||
|
|
@ -55,17 +55,17 @@ final class UserAppearanceScreenComponent: Component {
|
|||
}
|
||||
|
||||
let context: AccountContext
|
||||
let overNavigationContainer: UIView
|
||||
|
||||
init(
|
||||
context: AccountContext
|
||||
context: AccountContext,
|
||||
overNavigationContainer: UIView
|
||||
) {
|
||||
self.context = context
|
||||
self.overNavigationContainer = overNavigationContainer
|
||||
}
|
||||
|
||||
static func ==(lhs: UserAppearanceScreenComponent, rhs: UserAppearanceScreenComponent) -> Bool {
|
||||
if lhs.context !== rhs.context {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -166,8 +166,6 @@ final class UserAppearanceScreenComponent: Component {
|
|||
private let actionButton = ComponentView<Empty>()
|
||||
private let edgeEffectView: EdgeEffectView
|
||||
|
||||
private let backButton = PeerInfoHeaderNavigationButton()
|
||||
|
||||
private let tabSelector = ComponentView<Empty>()
|
||||
enum Section: Int32 {
|
||||
case profile
|
||||
|
|
@ -263,12 +261,6 @@ final class UserAppearanceScreenComponent: Component {
|
|||
self.containerView.addSubview(self.previewShadowView)
|
||||
|
||||
self.addSubview(self.edgeEffectView)
|
||||
|
||||
self.backButton.action = { [weak self] _, _ in
|
||||
if let self, let controller = self.environment?.controller() as? UserAppearanceScreen {
|
||||
controller.backPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
@ -963,6 +955,8 @@ final class UserAppearanceScreenComponent: Component {
|
|||
self.component = component
|
||||
self.state = state
|
||||
|
||||
transition.setFrame(view: component.overNavigationContainer, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: environment.navigationHeight)))
|
||||
|
||||
let theme = environment.theme
|
||||
|
||||
var animateTabChange = false
|
||||
|
|
@ -1072,16 +1066,6 @@ final class UserAppearanceScreenComponent: Component {
|
|||
.withUpdatedProfileBackgroundEmojiId(resolvedState.backgroundFileId)
|
||||
)
|
||||
}
|
||||
|
||||
let backSize = self.backButton.update(key: .back, presentationData: component.context.sharedContext.currentPresentationData.with { $0 }, height: 44.0)
|
||||
|
||||
self.backButton.updateContentsColor(backgroundColor: .clear, contentsColor: environment.theme.rootController.navigationBar.accentTextColor, canBeExpanded: true, transition: .animated(duration: 0.2, curve: .easeInOut))
|
||||
self.backButton.frame = CGRect(origin: CGPoint(x: environment.safeInsets.left + 16.0, y: environment.navigationHeight - 44.0), size: backSize)
|
||||
if self.backButton.view.superview == nil {
|
||||
if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar {
|
||||
navigationBar.view.addSubview(self.backButton.view)
|
||||
}
|
||||
}
|
||||
|
||||
var previewTransition = transition
|
||||
let transitionScale = (availableSize.height - 3.0) / availableSize.height
|
||||
|
|
@ -1148,10 +1132,10 @@ final class UserAppearanceScreenComponent: Component {
|
|||
environment: {},
|
||||
containerSize: CGSize(width: availableSize.width, height: 44.0)
|
||||
)
|
||||
let tabSelectorFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - tabSelectorSize.width) / 2.0), y: environment.statusBarHeight + floorToScreenPixels((environment.navigationHeight - environment.statusBarHeight - tabSelectorSize.height) / 2.0)), size: tabSelectorSize)
|
||||
let tabSelectorFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - tabSelectorSize.width) / 2.0), y: environment.statusBarHeight + 2.0 + floorToScreenPixels((environment.navigationHeight - environment.statusBarHeight - tabSelectorSize.height) / 2.0)), size: tabSelectorSize)
|
||||
if let tabSelectorView = self.tabSelector.view {
|
||||
if tabSelectorView.superview == nil {
|
||||
self.addSubview(tabSelectorView)
|
||||
component.overNavigationContainer.addSubview(tabSelectorView)
|
||||
}
|
||||
transition.setFrame(view: tabSelectorView, frame: tabSelectorFrame)
|
||||
}
|
||||
|
|
@ -1932,6 +1916,8 @@ final class UserAppearanceScreenComponent: Component {
|
|||
public class UserAppearanceScreen: ViewControllerComponentContainer {
|
||||
private let context: AccountContext
|
||||
|
||||
private let overNavigationContainer: UIView
|
||||
|
||||
private var didSetReady: Bool = false
|
||||
|
||||
public init(
|
||||
|
|
@ -1940,8 +1926,11 @@ public class UserAppearanceScreen: ViewControllerComponentContainer {
|
|||
) {
|
||||
self.context = context
|
||||
|
||||
self.overNavigationContainer = SparseContainerView()
|
||||
|
||||
super.init(context: context, component: UserAppearanceScreenComponent(
|
||||
context: context
|
||||
context: context,
|
||||
overNavigationContainer: self.overNavigationContainer
|
||||
), navigationBarAppearance: .default, theme: .default, updatedPresentationData: updatedPresentationData)
|
||||
|
||||
self.automaticallyControlPresentationContextLayout = false
|
||||
|
|
@ -1951,7 +1940,6 @@ public class UserAppearanceScreen: ViewControllerComponentContainer {
|
|||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
self.title = ""
|
||||
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: UIView())
|
||||
|
||||
self.ready.set(.never())
|
||||
|
||||
|
|
@ -1969,6 +1957,10 @@ public class UserAppearanceScreen: ViewControllerComponentContainer {
|
|||
|
||||
return componentView.attemptNavigation(complete: complete)
|
||||
}
|
||||
|
||||
if let navigationBar = self.navigationBar {
|
||||
navigationBar.view.insertSubview(self.overNavigationContainer, aboveSubview: navigationBar.backgroundView)
|
||||
}
|
||||
}
|
||||
|
||||
required public init(coder aDecoder: NSCoder) {
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ public final class TabBarComponent: Component {
|
|||
|
||||
public override init(frame: CGRect) {
|
||||
self.backgroundContainer = GlassBackgroundContainerView()
|
||||
self.liquidLensView = LiquidLensView(useBackgroundContainer: false)
|
||||
self.liquidLensView = LiquidLensView(kind: .externalContainer)
|
||||
|
||||
self.contextGestureContainerView = ContextControllerSourceView()
|
||||
self.contextGestureContainerView.isGestureEnabled = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue