Last active
November 2, 2022 01:49
-
-
Save eliyap/779dbe188c5c9a3c54a5408ac5b02c84 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct ContentView: View { | |
@State private var spinRadians: CGFloat = .zero | |
@State private var tiltRadians: CGFloat = .zero | |
private var spin: Angle { .radians(spinRadians) } | |
private var tilt: Angle { .radians(tiltRadians) } | |
var body: some View { | |
VStack { | |
iPhone12Sim(spin: spin, tilt: tilt) | |
Slider(value: $spinRadians, in: -CGFloat.pi...CGFloat.pi) | |
Slider(value: $tiltRadians, in: 0...CGFloat.pi/2) | |
} | |
.padding() | |
} | |
} | |
struct iPhone12Sim: View { | |
public let spin: Angle | |
public let tilt: Angle | |
public let scale: CGFloat = 2.5 | |
var body: some View { | |
ZStack { | |
iPhone12Shape(layer: .bottom, spin: spin, tilt: tilt, scale: scale) | |
.fill(Color.blue) | |
iPhone12Shape.Filler( | |
corner: .topLeft, | |
spin: spin, | |
tilt: tilt, | |
scale: scale | |
) | |
.fill(Color.blue) | |
iPhone12Shape.Filler( | |
corner: .bottomRight, | |
spin: spin, | |
tilt: tilt, | |
scale: scale | |
) | |
.fill(Color.blue) | |
iPhone12Shape(layer: .top, spin: spin, tilt: tilt, scale: scale) | |
.fill(Color.blue) | |
.brightness(-0.05) | |
iPhone12Shape.Face(spin: spin, tilt: tilt, scale: scale) | |
iPhone12Shape.Screen(spin: spin, tilt: tilt, scale: scale) | |
.fill(Color.gray) | |
iPhone12Shape.Island(spin: spin, tilt: tilt, scale: scale) | |
} | |
.border(Color.red) | |
} | |
} | |
struct iPhone12Shape: Shape { | |
/// https://www.apple.com/iphone-14-pro/specs/ | |
static let _height: CGFloat = 147.5 | |
static let _width: CGFloat = 71.5 | |
static let _depth: CGFloat = 7.85 /// Slightly thicker than true value | |
let height: CGFloat = Self._height | |
let width: CGFloat = Self._width | |
let depth: CGFloat = Self._depth | |
enum Layer { case top, bottom } | |
let layer: Layer | |
var z: CGFloat { | |
switch layer { | |
case .top: return -depth/2 | |
case .bottom: return +depth/2 | |
} | |
} | |
public var spin: Angle | |
public var tilt: Angle | |
public var scale: CGFloat | |
/** | |
According to https://www.apple.com/iphone-12/specs/ | |
screen is 2532‑by‑1170-pixel resolution at 460 ppi | |
i.e. 2.543in, or 64.6mm | |
That puts the bezel at ~`(71-64)/2 = 3.5mm` | |
The internal corner radius according to https://github.com/kylebshr/ScreenCorners | |
is 47.33pts, which at 3pt-per-pixels, according to https://useyourloaf.com/blog/iphone-12-screen-sizes/ | |
is `(47.33 * 3 / 460) = 7.84mm` | |
So we'll approximate the corner radius as `12mm`. | |
*/ | |
static let _bezel: CGFloat = 3.5 | |
static let _radius: CGFloat = 12 | |
let radius: CGFloat = Self._radius | |
func path(in rect: CGRect) -> Path { | |
let topDown = Path { path in | |
path.move(to: CGPoint(x: radius, y: 0)) | |
path.addArc( | |
center: CGPoint(x: radius, y: radius), | |
radius: radius, | |
startAngle: -.quarter, | |
endAngle: -.half, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: 0, y: height-radius)) | |
path.addArc( | |
center: CGPoint(x: radius, y: height-radius), | |
radius: radius, | |
startAngle: -.half, | |
endAngle: .quarter, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width-radius, y: height)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: height-radius), | |
radius: radius, | |
startAngle: .quarter, | |
endAngle: .zero, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width, y: radius)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: radius), | |
radius: radius, | |
startAngle: .zero, | |
endAngle: -.quarter, | |
clockwise: true | |
) | |
path.closeSubpath() | |
} | |
let transform = CGAffineTransform.identity | |
/// Center at origin. | |
.concatenating(CGAffineTransform(translationX: -width/2, y: -height/2)) | |
/// Apply scale. | |
.concatenating(CGAffineTransform(scaleX: scale, y: scale)) | |
/// Spin around origin. | |
.concatenating(CGAffineTransform(rotationAngle: spin.radians)) | |
/// Tilt around origin. | |
.concatenating(CGAffineTransform.tilt(tilt, z: z * scale)) | |
/// Center in frame. | |
.concatenating(CGAffineTransform(translationX: rect.width/2, y: rect.height/2)) | |
return topDown.applying(transform) | |
} | |
/// Fills in the corner bumps to preserve illusion of a solid object. | |
/// Treats the iPhone corners as thick "coins". | |
/// - Coins are round, and (cross-sectionally) can ignore `spin`. | |
/// - Coins only need 1 cardboard rectangle in the middle as filler. | |
/// | |
// __==TTTT==__ <- Top Surface | |
// [ ^^=__=^^ ] | |
// [ ] <- Filler Rectangle | |
// [ ] | |
// TT--____--TT <- Bottom Edge | |
/// | |
struct Filler: Shape { | |
enum Corner { case topLeft, topRight, bottomLeft, bottomRight } | |
let corner: Corner | |
let height: CGFloat = iPhone12Shape._height | |
let width: CGFloat = iPhone12Shape._width | |
let depth: CGFloat = iPhone12Shape._depth | |
let radius: CGFloat = iPhone12Shape._radius | |
public var spin: Angle | |
public var tilt: Angle | |
public var scale: CGFloat | |
func path(in rect: CGRect) -> Path { | |
let center: CGPoint | |
let x = width/2 - radius | |
let y = height/2 - radius | |
switch corner { | |
case .topLeft: center = CGPoint(x: -x, y: -y) | |
case .topRight: center = CGPoint(x: +x, y: -y) | |
case .bottomLeft: center = CGPoint(x: -x, y: +y) | |
case .bottomRight: center = CGPoint(x: +x, y: +y) | |
} | |
let transform = CGAffineTransform.identity | |
/// Spin around origin. | |
.concatenating(CGAffineTransform(rotationAngle: spin.radians)) | |
/// Apply scale. | |
.concatenating(CGAffineTransform(scaleX: scale, y: scale)) | |
/// Tilt around origin. | |
.concatenating(CGAffineTransform.tilt(tilt, z: 0)) | |
/// Center in frame. | |
.concatenating(CGAffineTransform(translationX: rect.width/2, y: rect.height/2)) | |
let newCenter = center.applying(transform) | |
let rectHeight = depth * sin(tilt.radians) * scale | |
let rectWidth = 2 * radius * scale | |
let origin = CGPoint( | |
x: newCenter.x - rectWidth/2, | |
y: newCenter.y - rectHeight/2 | |
) | |
return Path { path in | |
path.move(to: origin) | |
path.addRect(CGRect(origin: origin, size: CGSize( | |
width: rectWidth, | |
height: rectHeight | |
))) | |
} | |
} | |
} | |
/// The device's metal case intrudes slightly on the face. | |
/// This makes the black "dead screen area" appear smaller. | |
/// This represents the black face of the screen, excluding the front-facing metal. | |
struct Face: Shape { | |
/// In the official Apple product shot PNGs, we have | |
/// - 58 pixels from screen to device edge | |
/// - 37 pixels from screen to black edge | |
static let metal: CGFloat = iPhone12Shape._bezel * (21.0/58.0) | |
let height: CGFloat = iPhone12Shape._height - 2*Self.metal | |
let width: CGFloat = iPhone12Shape._width - 2*Self.metal | |
let depth: CGFloat = iPhone12Shape._depth | |
let radius: CGFloat = iPhone12Shape._radius - Self.metal | |
var z: CGFloat { -depth / 2 } | |
public var spin: Angle | |
public var tilt: Angle | |
public var scale: CGFloat | |
func path(in rect: CGRect) -> Path { | |
let topDown = Path { path in | |
path.move(to: CGPoint(x: radius, y: 0)) | |
path.addArc( | |
center: CGPoint(x: radius, y: radius), | |
radius: radius, | |
startAngle: -.quarter, | |
endAngle: -.half, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: 0, y: height-radius)) | |
path.addArc( | |
center: CGPoint(x: radius, y: height-radius), | |
radius: radius, | |
startAngle: -.half, | |
endAngle: .quarter, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width-radius, y: height)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: height-radius), | |
radius: radius, | |
startAngle: .quarter, | |
endAngle: .zero, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width, y: radius)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: radius), | |
radius: radius, | |
startAngle: .zero, | |
endAngle: -.quarter, | |
clockwise: true | |
) | |
path.closeSubpath() | |
} | |
let transform = CGAffineTransform.identity | |
/// Center at origin. | |
.concatenating(CGAffineTransform(translationX: -width/2, y: -height/2)) | |
/// Apply scale. | |
.concatenating(CGAffineTransform(scaleX: scale, y: scale)) | |
/// Spin around origin. | |
.concatenating(CGAffineTransform(rotationAngle: spin.radians)) | |
/// Tilt around origin. | |
.concatenating(CGAffineTransform.tilt(tilt, z: z * scale)) | |
/// Center in frame. | |
.concatenating(CGAffineTransform(translationX: rect.width/2, y: rect.height/2)) | |
return topDown.applying(transform) | |
} | |
} | |
struct Screen: Shape { | |
let height: CGFloat = iPhone12Shape._height - 2*iPhone12Shape._bezel | |
let width: CGFloat = iPhone12Shape._width - 2*iPhone12Shape._bezel | |
let depth: CGFloat = iPhone12Shape._depth | |
let radius: CGFloat = iPhone12Shape._radius - iPhone12Shape._bezel | |
var z: CGFloat { -depth / 2 } | |
public var spin: Angle | |
public var tilt: Angle | |
public var scale: CGFloat | |
func path(in rect: CGRect) -> Path { | |
let topDown = Path { path in | |
path.move(to: CGPoint(x: radius, y: 0)) | |
path.addArc( | |
center: CGPoint(x: radius, y: radius), | |
radius: radius, | |
startAngle: -.quarter, | |
endAngle: -.half, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: 0, y: height-radius)) | |
path.addArc( | |
center: CGPoint(x: radius, y: height-radius), | |
radius: radius, | |
startAngle: -.half, | |
endAngle: .quarter, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width-radius, y: height)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: height-radius), | |
radius: radius, | |
startAngle: .quarter, | |
endAngle: .zero, | |
clockwise: true | |
) | |
path.addLine(to: CGPoint(x: width, y: radius)) | |
path.addArc( | |
center: CGPoint(x: width-radius, y: radius), | |
radius: radius, | |
startAngle: .zero, | |
endAngle: -.quarter, | |
clockwise: true | |
) | |
path.closeSubpath() | |
} | |
let transform = CGAffineTransform.identity | |
/// Center at origin. | |
.concatenating(CGAffineTransform(translationX: -width/2, y: -height/2)) | |
/// Apply scale. | |
.concatenating(CGAffineTransform(scaleX: scale, y: scale)) | |
/// Spin around origin. | |
.concatenating(CGAffineTransform(rotationAngle: spin.radians)) | |
/// Tilt around origin. | |
.concatenating(CGAffineTransform.tilt(tilt, z: z * scale)) | |
/// Center in frame. | |
.concatenating(CGAffineTransform(translationX: rect.width/2, y: rect.height/2)) | |
return topDown.applying(transform) | |
} | |
} | |
struct Island: Shape { | |
/// 3x retina, 25.4 mm per inch, 460ppi. | |
static let mmPerPixel = (3 * 25.4 / 460) | |
/// https://betterprogramming.pub/dynamic-island-animation-5869fbce41e6 | |
/// > The dynamic island has 11-pixel top padding. Its width is 126 and its height is 37.33. | |
let padding: CGFloat = 11 * Self.mmPerPixel | |
let height: CGFloat = 37.33 * Self.mmPerPixel | |
let width: CGFloat = 126 * Self.mmPerPixel | |
let depth: CGFloat = iPhone12Shape._depth | |
var radius: CGFloat { height / 2 } | |
var z: CGFloat { -depth / 2 } | |
public var spin: Angle | |
public var tilt: Angle | |
public var scale: CGFloat | |
func path(in rect: CGRect) -> Path { | |
let topDown = Path { path in | |
let origin = CGPoint(x: 0, y: iPhone12Shape._bezel + padding + height/2 - iPhone12Shape._height/2) | |
path.move(to: origin) | |
path.addRoundedRect( | |
in: CGRect(origin: origin, size: CGSize(width: width, height: height)), | |
cornerSize: CGSize(width :radius, height: radius), | |
style: .circular, | |
transform: .identity | |
) | |
} | |
let transform = CGAffineTransform.identity | |
/// Center at origin. | |
.concatenating(CGAffineTransform(translationX: -width/2, y: -height/2)) | |
/// Apply scale. | |
.concatenating(CGAffineTransform(scaleX: scale, y: scale)) | |
/// Spin around origin. | |
.concatenating(CGAffineTransform(rotationAngle: spin.radians)) | |
/// Tilt around origin. | |
.concatenating(CGAffineTransform.tilt(tilt, z: z * scale)) | |
/// Center in frame. | |
.concatenating(CGAffineTransform(translationX: rect.width/2, y: rect.height/2)) | |
return topDown.applying(transform) | |
} | |
} | |
} | |
extension CGAffineTransform { | |
static func tilt(_ angle: Angle, z: CGFloat) -> CGAffineTransform { | |
/// `x` unchanged. | |
let (a, c, tx): (CGFloat, CGFloat, CGFloat) = (1, 0, 0) | |
/// `y` incorporates `z` when tilting. | |
let (b, d, ty): (CGFloat, CGFloat, CGFloat) = (0, cos(angle.radians), z * sin(angle.radians)) | |
return CGAffineTransformMake(a, b, c, d, tx, ty) | |
} | |
} | |
extension Angle { | |
static let quarter = Angle(radians: .pi / 2) | |
static let half = Angle(radians: .pi) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment