Skip to content

Commit d7e6034

Browse files
isaacclaude
andcommitted
Profile expansion: the background bounces with the content
The open's spring overshoots past rest, and everything lent to the transition rides it: the avatar, the title and status, the buttons and the page below the header went 10 pt past their places and came back. The cover is not lent to move, only clipped at the page's top (pageEdgeViews), and the edge's rule held its gradient and pattern still the whole way, so they stood still while everything on them bounced. What a page edge shows now holds still only while the page comes in from above (so the cover still never sticks out below it) and lands with the page past its place: the edge's sublayer counter-translation is -min(page, 0) instead of -page. The gifts around the avatar, which were only faded, are the loan's new landingViews, which take only the page's overshoot. The cover's gradient view already reaches 1000 pt above it, so moving it down shows no gap. The close and the way in never overshoot downward, so they are unchanged. Measured on the simulator, tracking the name, the buttons and the cover pattern through the overshoot: before, the pattern stayed at 0 while the name and buttons went to +10 pt and back; after, it follows them (9/8, 7/7, 5/4, 2/2, 0/0 pt, pattern/name). Tests: NavigationExpansionRendererTests test_pageEdge_ridesThePage_contentLandsWithIt and test_pageEdge_keyframed (rewritten for the new rule, failing before the fix), test_landing_takesOnlyThePagesOvershoot (new). Three tests in the suite fail before and after this change: test_begin_atZero_showsThePill, test_models_areTheLastKeyframes_afterTheTint and test_platters_appearWithTheirButtons still read alpha and the old keyframes after e686160 moved the renderer to an opacity-holding animation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f197ea0 commit d7e6034

4 files changed

Lines changed: 87 additions & 25 deletions

File tree

‎submodules/TelegramUI/Components/NavigationExpansionTransition/Sources/NavigationExpansionProviding.swift‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,15 @@ public final class NavigationExpansionDestinationLoan {
107107
/// itself, which have transforms of their own.
108108
public let pageViews: [UIView]
109109
/// Views clipping at a bottom edge that is the page's top (the profile's cover, whose bottom band is the music strip):
110-
/// the edge rides the page while what they show holds still, so the page never leaves a strip of the cover below it.
111-
/// They stay in their own superviews and keep their own transforms and sublayer transforms.
110+
/// the edge rides the page while what they show holds still, so the page never leaves a strip of the cover below it,
111+
/// until the page passes its resting place; past it what they show lands with the page, so its overshoot moves the
112+
/// background with everything on it. They stay in their own superviews and keep their own transforms and sublayer
113+
/// transforms.
112114
public let pageEdgeViews: [UIView]
115+
/// The rest of the background (the gifts around the profile's avatar): held still while the page comes in, landing
116+
/// with it past its resting place, as what the page edges show does. They stay in their own superviews and keep their
117+
/// own transforms.
118+
public let landingViews: [UIView]
113119
/// The colour the drops' glass tints toward (the profile's colour; nil: none, the plain profile).
114120
public let glassTint: UIColor?
115121
/// The screen's corner radii (`ContainerViewLayout.screenCornerRadii`), which differ on iPhone Duo's cover screen.
@@ -119,7 +125,7 @@ public final class NavigationExpansionDestinationLoan {
119125
public let backgroundClosing: ClosedRange<CGFloat>?
120126
public let flyingViews: [UIView]
121127

122-
public init(avatar: CGRect?, photo: CGRect?, avatarView: UIView?, avatarRestFrame: CGRect, labels: [NavigationExpansionLabel], labelColors: [UIColor], carriedEdge: CGFloat, appearingViews: [UIView], appearingCompanions: [[UIView]] = [], overlayViews: [UIView] = [], backgroundViews: [UIView], listViews: [UIView], pageViews: [UIView] = [], pageEdgeViews: [UIView] = [], patternViews: [UIView], glassTint: UIColor?, screenCornerRadii: CornerRadii, backButtonFrame: CGRect?, backgroundOpening: ClosedRange<CGFloat>? = nil, backgroundClosing: ClosedRange<CGFloat>? = nil, flyingViews: [UIView] = []) {
128+
public init(avatar: CGRect?, photo: CGRect?, avatarView: UIView?, avatarRestFrame: CGRect, labels: [NavigationExpansionLabel], labelColors: [UIColor], carriedEdge: CGFloat, appearingViews: [UIView], appearingCompanions: [[UIView]] = [], overlayViews: [UIView] = [], backgroundViews: [UIView], listViews: [UIView], pageViews: [UIView] = [], pageEdgeViews: [UIView] = [], landingViews: [UIView] = [], patternViews: [UIView], glassTint: UIColor?, screenCornerRadii: CornerRadii, backButtonFrame: CGRect?, backgroundOpening: ClosedRange<CGFloat>? = nil, backgroundClosing: ClosedRange<CGFloat>? = nil, flyingViews: [UIView] = []) {
123129
self.avatar = avatar
124130
self.photo = photo
125131
self.avatarView = avatarView
@@ -134,6 +140,7 @@ public final class NavigationExpansionDestinationLoan {
134140
self.listViews = listViews
135141
self.pageViews = pageViews
136142
self.pageEdgeViews = pageEdgeViews
143+
self.landingViews = landingViews
137144
self.patternViews = patternViews
138145
self.glassTint = glassTint
139146
self.screenCornerRadii = screenCornerRadii

‎submodules/TelegramUI/Components/NavigationExpansionTransition/Sources/NavigationExpansionRendererImpl.swift‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,15 @@ public final class NavigationExpansionRendererImpl: NavigationExpansionRenderer
112112
let rest: CATransform3D
113113
}
114114
private var followers: [Follower] = []
115-
/// A view of the page below the header (`NavigationExpansionDestinationLoan.pageViews`, `pageEdgeViews`), measured
116-
/// before anything is lent: how many screen points one of its superlayer's points is, vertically, and its own
117-
/// transform.
115+
/// A view of the page below the header (`NavigationExpansionDestinationLoan.pageViews`, `pageEdgeViews`,
116+
/// `landingViews`), measured before anything is lent: how many screen points one of its superlayer's points is,
117+
/// vertically, and its own transform.
118118
private struct PageRider {
119119
let layer: CALayer
120120
let unit: CGFloat
121121
let rest: CATransform3D
122+
/// A landing view's (`landingViews`): it takes only the page's overshoot past its resting place.
123+
let landsOnly: Bool
122124
/// An edge's (`pageEdgeViews`): how many screen points one of its own points is, vertically, and its own sublayer
123125
/// transform, which takes its content back to where it stands.
124126
let content: (unit: CGFloat, rest: CATransform3D)?
@@ -205,14 +207,14 @@ public final class NavigationExpansionRendererImpl: NavigationExpansionRenderer
205207
self.flyers.append(Flyer(layer: view.layer, restCenter: CGPoint(x: restFrame.midX, y: restFrame.midY), restSize: restFrame.size, unit: unit, rest: view.layer.transform))
206208
self.touchedLayers.append(view.layer)
207209
}
208-
for (view, isEdge) in destinationLoan.pageViews.map({ ($0, false) }) + destinationLoan.pageEdgeViews.map({ ($0, true) }) {
210+
for (view, isEdge, landsOnly) in destinationLoan.pageViews.map({ ($0, false, false) }) + destinationLoan.pageEdgeViews.map({ ($0, true, false) }) + destinationLoan.landingViews.map({ ($0, false, true) }) {
209211
guard let superlayer = view.layer.superlayer else {
210212
continue
211213
}
212214
let point = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)
213215
let unit = superlayer.convert(point, to: screen.layer).height
214216
let content = isEdge ? (unit: view.layer.convert(point, to: screen.layer).height, rest: view.layer.sublayerTransform) : nil
215-
self.pageRiders.append(PageRider(layer: view.layer, unit: unit, rest: view.layer.transform, content: content))
217+
self.pageRiders.append(PageRider(layer: view.layer, unit: unit, rest: view.layer.transform, landsOnly: landsOnly, content: content))
216218
self.touchedLayers.append(view.layer)
217219
}
218220

@@ -418,15 +420,16 @@ public final class NavigationExpansionRendererImpl: NavigationExpansionRenderer
418420
}
419421

420422
/// A page view's transform with the page `page` points below its resting place: after its own, in its superlayer's
421-
/// points.
423+
/// points. A landing view takes only the overshoot past its resting place.
422424
private func pageTransform(_ rider: PageRider, page: CGFloat) -> CATransform3D {
423-
return CATransform3DConcat(rider.rest, CATransform3DMakeTranslation(0.0, page / max(rider.unit, 0.0001), 0.0))
425+
let offset = rider.landsOnly ? max(page, 0.0) : page
426+
return CATransform3DConcat(rider.rest, CATransform3DMakeTranslation(0.0, offset / max(rider.unit, 0.0001), 0.0))
424427
}
425428

426-
/// An edge's sublayer transform with the page `page` points below its resting place: its content as far back, in its
427-
/// own points, so only the edge moves.
429+
/// An edge's sublayer transform with the page `page` points below its resting place: while the page is above it, its
430+
/// content as far back, in its own points, so only the edge moves; past it, none, so the content lands with the page.
428431
private func pageContentTransform(_ rider: PageRider, page: CGFloat) -> CATransform3D? {
429-
return rider.content.map { CATransform3DConcat($0.rest, CATransform3DMakeTranslation(0.0, -page / max($0.unit, 0.0001), 0.0)) }
432+
return rider.content.map { CATransform3DConcat($0.rest, CATransform3DMakeTranslation(0.0, -min(page, 0.0) / max($0.unit, 0.0001), 0.0)) }
430433
}
431434

432435
private func originContentPlacement(_ frame: ExpansionFrame) -> (center: CGPoint, transform: CATransform3D)? {

‎submodules/TelegramUI/Components/NavigationExpansionTransition/Tests/NavigationExpansionRendererTests.swift‎

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ private final class FakeDestination: NavigationExpansionDestinationProviding {
7272
var pageViews: [UIView] = []
7373
/// Views clipping at the page's top edge (the profile's cover).
7474
var pageEdgeViews: [UIView] = []
75+
/// The rest of the background, landing with the page (the profile's gifts around its avatar).
76+
var landingViews: [UIView] = []
7577
var path: ExpansionPath = .origin
7678
var screenCornerRadii = makeExpansionGeometry().screenCornerRadii
7779
var lends = 0
@@ -105,7 +107,7 @@ private final class FakeDestination: NavigationExpansionDestinationProviding {
105107
return NavigationExpansionDestinationLoan(avatar: g.avatar, photo: g.photo, avatarView: self.avatarView, avatarRestFrame: self.path == .photo ? g.photo! : g.avatar!, labels: [
106108
NavigationExpansionLabel(view: self.titleView, textCenter: CGPoint(x: 79.0, y: 17.0), textSize: CGSize(width: 158.0, height: 34.0), screenCenter: g.labels[0].destinationCenter, coloring: .multiply([self.titleView.layer], .white)),
107109
NavigationExpansionLabel(view: self.statusView, textCenter: CGPoint(x: 65.0, y: 10.0), textSize: CGSize(width: 130.0, height: 20.0), screenCenter: g.labels[1].destinationCenter, coloring: .multiply([self.statusView.layer], .white), followers: self.statusFollowers)
108-
], labelColors: [.white, .white], carriedEdge: g.carriedEdge, appearingViews: self.buttons, appearingCompanions: self.platters.map { [$0] }, overlayViews: self.overlays, backgroundViews: [self.background], listViews: [self.list], pageViews: self.pageViews, pageEdgeViews: self.pageEdgeViews, patternViews: [], glassTint: .purple, screenCornerRadii: self.screenCornerRadii, backButtonFrame: CGRect(x: 16.0, y: 64.0, width: 44.0, height: 44.0))
110+
], labelColors: [.white, .white], carriedEdge: g.carriedEdge, appearingViews: self.buttons, appearingCompanions: self.platters.map { [$0] }, overlayViews: self.overlays, backgroundViews: [self.background], listViews: [self.list], pageViews: self.pageViews, pageEdgeViews: self.pageEdgeViews, landingViews: self.landingViews, patternViews: [], glassTint: .purple, screenCornerRadii: self.screenCornerRadii, backButtonFrame: CGRect(x: 16.0, y: 64.0, width: 44.0, height: 44.0))
109111
}
110112

111113
func returnExpansionDestination() {
@@ -875,10 +877,12 @@ final class NavigationExpansionRendererTests: XCTestCase {
875877
return (edge, content)
876878
}
877879

878-
// The cover's bottom edge rides the page, its content still: wherever the status's host stands, the edge stands as
879-
// far from its place as the page does, and the gradient where it rests. Riding in from above, the page's music strip
880-
// (the cover's bottom band) otherwise left a strip of the cover below it, 40 pt at first.
881-
func test_pageEdge_ridesThePageWithItsContentStill() {
880+
// The cover's bottom edge rides the page; its content holds still while the page comes in from above and lands with it
881+
// past its place: wherever the status's host stands, the edge stands as far from its place as the page does, and the
882+
// gradient as far as the page's overshoot. Riding in from above, the page's music strip (the cover's bottom band)
883+
// otherwise left a strip of the cover below it, 40 pt at first; held still through the overshoot, the gradient and
884+
// its pattern stood still while the avatar, the name and the page bounced over them.
885+
func test_pageEdge_ridesThePage_contentLandsWithIt() {
882886
var cover: (edge: UIView, content: UIView)?
883887
var rest: (edge: CGRect, content: CGRect)?
884888
let (renderer, container, _, _, _) = self.make(configure: { destination in
@@ -900,31 +904,76 @@ final class NavigationExpansionRendererTests: XCTestCase {
900904
XCTAssertEqual(edge.maxY - rest.edge.maxY, delta, accuracy: 0.01, "t=\(time)")
901905
XCTAssertEqual(edge.height, rest.edge.height, accuracy: 0.01, "t=\(time)")
902906
let content = cover.content.convert(cover.content.bounds, to: container)
903-
XCTAssertEqual(content.minY, rest.content.minY, accuracy: 0.01, "t=\(time): the gradient holds still")
907+
XCTAssertEqual(content.minY - rest.content.minY, max(delta, 0.0), accuracy: 0.01, "t=\(time): the gradient holds still until the page passes its place")
904908
XCTAssertEqual(content.height, rest.content.height, accuracy: 0.01, "t=\(time)")
905909
if time == 0.6 {
906910
XCTAssertLessThan(delta, -20.0, "mid-flight the edge is still above its place")
907911
}
912+
if time == 1.04 {
913+
XCTAssertGreaterThan(delta, 2.0, "the page goes past its place")
914+
}
908915
}
909916
}
910917

911-
// On keyframes too: the edge's transform and its content's sublayer transform cancel in every frame, from above its
912-
// place to its rest.
918+
// On keyframes too: the edge's transform and its content's sublayer transform cancel while the edge is above its
919+
// place, and leave the content the page's overshoot past it.
913920
func test_pageEdge_keyframed() {
914921
var cover: (edge: UIView, content: UIView)?
915922
let (renderer, _, _, _, _) = self.make(configure: { cover = self.makePageEdge($0) })
916-
let timeline = self.timeline(from: 0.0, to: 1.0, opening: true)
923+
let timeline = self.timeline(from: 0.0, to: 1.04, opening: true)
917924
renderer.animate(timeline: timeline)
918925
guard let edge = cover?.edge, let transform = edge.layer.animation(forKey: "navigationExpansion.transform") as? CAKeyframeAnimation, let transforms = transform.values as? [NSValue], let sublayer = edge.layer.animation(forKey: "navigationExpansion.sublayerTransform") as? CAKeyframeAnimation, let sublayers = sublayer.values as? [NSValue] else {
919926
return XCTFail("no edge keyframes")
920927
}
921928
XCTAssertEqual(transforms.count, timeline.timing.sampleTimes.count)
922929
XCTAssertEqual(sublayers.count, transforms.count)
923930
XCTAssertLessThan(transforms[0].caTransform3DValue.m42, -20.0, "it starts above its place")
931+
var landed: CGFloat = 0.0
924932
for (edgeValue, contentValue) in zip(transforms, sublayers) {
925-
XCTAssertEqual(edgeValue.caTransform3DValue.m42 + contentValue.caTransform3DValue.m42, 0.0, accuracy: 1e-6)
933+
let edge = edgeValue.caTransform3DValue.m42
934+
let content = edge + contentValue.caTransform3DValue.m42
935+
XCTAssertEqual(content, max(edge, 0.0), accuracy: 1e-6)
936+
landed = max(landed, content)
937+
}
938+
XCTAssertGreaterThan(landed, 2.0, "the content lands with the overshoot")
939+
}
940+
941+
// The rest of the background (the profile's gifts around its avatar) holds still while the page comes in and lands
942+
// with it past its place, as the cover's gradient does, keeping its own transform; it is restored on every ending.
943+
func test_landing_takesOnlyThePagesOvershoot() {
944+
for atTop in [true, false] {
945+
var landing: UIView?
946+
var rest: CGRect?
947+
let (renderer, container, _, _, _) = self.make(configure: { destination in
948+
let view = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 402.0, height: 360.0))
949+
view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
950+
destination.top!.addSubview(view)
951+
destination.landingViews = [view]
952+
landing = view
953+
rest = view.convert(view.bounds, to: destination.top!.superview!)
954+
})
955+
guard let landing, let rest else {
956+
return XCTFail()
957+
}
958+
let statusRest = makeExpansionGeometry().labels[1].destinationCenter.y
959+
for time: CGFloat in [0.6, 1.0, 1.04] {
960+
renderer.update(time: time, isOpening: true, pose: .none)
961+
guard let host = renderer.destinationLabelHostsForTesting.last else {
962+
return XCTFail("no status host")
963+
}
964+
let delta = host.layer.position.y - statusRest
965+
let now = landing.convert(landing.bounds, to: container)
966+
XCTAssertEqual(now.minY - rest.minY, max(delta, 0.0), accuracy: 0.01, "t=\(time)")
967+
XCTAssertEqual(now.height, rest.height, accuracy: 0.01, "t=\(time): its own transform is kept")
968+
if time == 1.04 {
969+
XCTAssertGreaterThan(delta, 2.0, "the page goes past its place")
970+
}
971+
}
972+
renderer.animate(timeline: self.timeline(from: 0.0, to: 1.0, opening: true))
973+
renderer.end(atTop: atTop)
974+
XCTAssertEqual(landing.transform, CGAffineTransform(scaleX: 0.9, y: 0.9))
975+
XCTAssertNil(landing.layer.animation(forKey: "navigationExpansion.transform"))
926976
}
927-
XCTAssertEqual(transforms[transforms.count - 1].caTransform3DValue.m42, 0.0, accuracy: 1.0)
928977
}
929978

930979
func test_companion_goesHome() {

‎submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoExpansionDestination.swift‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,11 @@ final class PeerInfoExpansionDestination: NavigationExpansionDestinationProvidin
127127
// strip.
128128
pageViews: [node.scrollNode.view] + header.expansionPageViews,
129129
// The cover clips at the header's bottom, the music strip's band: the edge rides the page with the band, its
130-
// gradient and pattern held still, as the prototype's gradient (which has no edge) is.
130+
// gradient and pattern held still while the page comes in, as the prototype's gradient (which has no edge) is,
131+
// and landing with it past its place, so the background bounces with the avatar, the title and the page.
131132
pageEdgeViews: [banner],
133+
// The gifts around the avatar land with the cover.
134+
landingViews: header.giftsCover.view.flatMap { [$0] } ?? [],
132135
patternViews: header.giftsCover.view.flatMap { [$0] } ?? [],
133136
glassTint: header.expansionGlassTint,
134137
screenCornerRadii: layout.screenCornerRadii,

0 commit comments

Comments
 (0)