From d1cb0f693958a3f39432029ed234f6b7d1c4f056 Mon Sep 17 00:00:00 2001 From: xuty Date: Mon, 3 Feb 2025 22:47:28 +0800 Subject: [PATCH 1/2] Add SliverPinnedHeader and SliverGroup --- Package.resolved | 4 +- .../Shaft/Rendering/RenderSliverGroup.swift | 450 ++++++++++++++++++ Sources/Shaft/Rendering/RenderViewport.swift | 3 + Sources/Shaft/Rendering/ViewportOffset.swift | 2 +- .../Shaft/Widgets/Framework/Framework.swift | 62 ++- .../ScrollPositionWithSingleContext.swift | 20 +- Sources/Shaft/Widgets/Scroll/Scrollable.swift | 2 +- Sources/Shaft/Widgets/ScrollView.swift | 6 +- Sources/Shaft/Widgets/Sliver.swift | 146 +++++- .../Shaft/Widgets/SliverPinnedHeader.swift | 96 ++++ Sources/Shaft/Widgets/Text/Text.swift | 30 +- Tests/ShaftTests/TestUtils/WidgetTester.swift | 239 +++++++++- .../Widgets/SliverPinnedHeaderTest.swift | 152 ++++++ 13 files changed, 1136 insertions(+), 76 deletions(-) create mode 100644 Sources/Shaft/Rendering/RenderSliverGroup.swift create mode 100644 Sources/Shaft/Widgets/SliverPinnedHeader.swift create mode 100644 Tests/ShaftTests/Widgets/SliverPinnedHeaderTest.swift diff --git a/Package.resolved b/Package.resolved index df3f23b..e80cb61 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Rainbow", "state" : { - "revision" : "0c627a4f8a39ef37eadec1ceec02e4a7f55561ac", - "version" : "4.1.0" + "revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3", + "version" : "4.0.1" } }, { diff --git a/Sources/Shaft/Rendering/RenderSliverGroup.swift b/Sources/Shaft/Rendering/RenderSliverGroup.swift new file mode 100644 index 0000000..5ac5631 --- /dev/null +++ b/Sources/Shaft/Rendering/RenderSliverGroup.swift @@ -0,0 +1,450 @@ +import SwiftMath + +/// A sliver that places multiple sliver children in a linear array along the cross +/// axis. +/// +/// Since the extent of the viewport in the cross axis direction is finite, +/// this extent will be divided up and allocated to the children slivers. +/// +/// The algorithm for dividing up the cross axis extent is as follows. +/// Every widget has a [SliverPhysicalParentData.crossAxisFlex] value associated with them. +/// First, lay out all of the slivers with flex of 0 or null, in which case the slivers themselves will +/// figure out how much cross axis extent to take up. For example, [SliverConstrainedCrossAxis] +/// is an example of a widget which sets its own flex to 0. Then [RenderSliverCrossAxisGroup] will +/// divide up the remaining space to all the remaining children proportionally +/// to each child's flex factor. By default, children of [SliverCrossAxisGroup] +/// are setup to have a flex factor of 1, but a different flex factor can be +/// specified via the [SliverCrossAxisExpanded] widgets. +// class RenderSliverCrossAxisGroup extends RenderSliver with ContainerRenderObjectMixin { +public class RenderSliverCrossAxisGroup: RenderSliver, RenderObjectWithChildren { + public typealias ChildType = RenderSliver + public typealias ParentDataType = SliverPhysicalContainerParentData + public var childMixin = RenderContainerMixin() + + public override func setupParentData(_ child: RenderObject) { + if !(child.parentData is SliverPhysicalContainerParentData) { + child.parentData = SliverPhysicalContainerParentData() + (child.parentData as! SliverPhysicalParentData).crossAxisFlex = 1 + } + } + + public override func childMainAxisPosition(_ child: RenderObject) -> Float { + return 0.0 + } + + public override func childCrossAxisPosition(_ child: RenderObject) -> Float { + let paintOffset = (child.parentData as! SliverPhysicalParentData).paintOffset + switch sliverConstraints.axis { + case .vertical: + return paintOffset.dx + case .horizontal: + return paintOffset.dy + } + } + + public override func performLayout() { + let constraints = sliverConstraints + + // Iterate through each sliver. + // Get the parent's dimensions. + let crossAxisExtent = constraints.crossAxisExtent + assert(crossAxisExtent.isFinite) + + // First, layout each child with flex == 0 or null. + var totalFlex = 0 + var remainingExtent = crossAxisExtent + var child = firstChild + while child != nil { + let childParentData = child!.parentData as! SliverPhysicalParentData + let flex = childParentData.crossAxisFlex ?? 0 + if flex == 0 { + // If flex is 0 or null, then the child sliver must provide their own crossAxisExtent. + assert(_assertOutOfExtent(remainingExtent)) + child!.layout( + constraints.copyWith(crossAxisExtent: remainingExtent), + parentUsesSize: true + ) + let childCrossAxisExtent = child!.geometry!.crossAxisExtent + assert(childCrossAxisExtent != nil) + remainingExtent = max(0.0, remainingExtent - childCrossAxisExtent!) + } else { + totalFlex += flex + } + child = childAfter(child!) + } + let extentPerFlexValue = remainingExtent / Float(totalFlex) + + child = firstChild + + // At this point, all slivers with constrained cross axis should already be laid out. + // Layout the rest and keep track of the child geometry with greatest scrollExtent. + geometry = SliverGeometry.zero + while child != nil { + let childParentData = child!.parentData as! SliverPhysicalParentData + let flex = childParentData.crossAxisFlex ?? 0 + var childExtent: Float + if flex != 0 { + childExtent = extentPerFlexValue * Float(flex) + assert(_assertOutOfExtent(childExtent)) + child!.layout( + constraints.copyWith( + crossAxisExtent: extentPerFlexValue * Float(flex) + ), + parentUsesSize: true + ) + } else { + childExtent = child!.geometry!.crossAxisExtent! + } + let childLayoutGeometry = child!.geometry! + if geometry!.scrollExtent < childLayoutGeometry.scrollExtent { + geometry = childLayoutGeometry + } + child = childAfter(child!) + } + + // Go back and correct any slivers using a negative paint offset if it tries + // to paint outside the bounds of the sliver group. + child = firstChild + var offset: Float = 0.0 + while child != nil { + let childParentData = child!.parentData as! SliverPhysicalParentData + let childLayoutGeometry = child!.geometry! + let remainingExtent = geometry!.scrollExtent - constraints.scrollOffset + let paintCorrection = + childLayoutGeometry.paintExtent > remainingExtent + ? childLayoutGeometry.paintExtent - remainingExtent + : 0.0 + let childExtent = + child!.geometry!.crossAxisExtent ?? extentPerFlexValue + * Float(childParentData.crossAxisFlex ?? 0) + // Set child parent data. + childParentData.paintOffset = + switch constraints.axis { + case .vertical: + Offset(offset, -paintCorrection) + case .horizontal: + Offset(-paintCorrection, offset) + } + offset += childExtent + child = childAfter(child!) + } + } + + public override func paint(context: PaintingContext, offset: Offset) { + var child = firstChild + + while child != nil { + if child!.geometry!.visible { + let childParentData = child!.parentData as! SliverPhysicalParentData + context.paintChild(child!, offset: offset + childParentData.paintOffset) + } + child = childAfter(child!) + } + } + + public override func applyPaintTransform( + _ child: RenderObject, + transform: inout Matrix4x4f + ) { + let childParentData = child.parentData as! SliverPhysicalParentData + childParentData.applyPaintTransform(&transform) + } + + public override func hitTestChildren( + _ result: SliverHitTestResult, + mainAxisPosition: Float, + crossAxisPosition: Float + ) -> Bool { + var child = lastChild + while child != nil { + let isHit = result.addWithAxisOffset( + paintOffset: nil, + mainAxisOffset: childMainAxisPosition(child!), + crossAxisOffset: childCrossAxisPosition(child!), + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + hitTest: child!.hitTest + ) + if isHit { + return true + } + child = childBefore(child!) + } + return false + } +} + +private func _assertOutOfExtent(_ extent: Float) -> Bool { + if extent <= 0.0 { + preconditionFailure( + """ + SliverCrossAxisGroup ran out of extent before child could be laid out. + + SliverCrossAxisGroup lays out any slivers with a constrained cross \ + axis before laying out those which expand. In this case, cross axis \ + extent was used up before the next sliver could be laid out. + + Make sure that the total amount of extent allocated by constrained \ + child slivers does not exceed the cross axis extent that is available \ + for the SliverCrossAxisGroup. + """ + ) + } + return true +} + +/// A sliver that places multiple sliver children in a linear array along the +/// main axis. +/// +/// The layout algorithm lays out slivers one by one. If the sliver is at the top +/// of the viewport or above the top, then we pass in a nonzero [SliverConstraints.scrollOffset] +/// to inform the sliver at what point along the main axis we should start layout. +/// For the slivers that come after it, we compute the amount of space taken up so +/// far to be used as the [SliverPhysicalParentData.paintOffset] and the +/// [SliverConstraints.remainingPaintExtent] to be passed in as a constraint. +/// +/// Finally, this sliver will also ensure that all child slivers are painted within +/// the total scroll extent of the group by adjusting the child's +/// [SliverPhysicalParentData.paintOffset] as necessary. This can happen for +/// slivers such as [SliverPersistentHeader] which, when pinned, positions itself +/// at the top of the [Viewport] regardless of the scroll offset. +public class RenderSliverMainAxisGroup: RenderSliver, RenderObjectWithChildren { + public typealias ChildType = RenderSliver + public typealias ParentDataType = SliverPhysicalContainerParentData + public var childMixin = RenderContainerMixin() + + public override func setupParentData(_ child: RenderObject) { + if !(child.parentData is SliverPhysicalContainerParentData) { + child.parentData = SliverPhysicalContainerParentData() + } + } + + public override func childScrollOffset(_ child: RenderObject) -> Float? { + assert(child.parent === self) + let growthDirection = sliverConstraints.growthDirection + switch growthDirection { + case .forward: + var childScrollOffset: Float = 0.0 + var current = childBefore(child as! RenderSliver) + while current != nil { + childScrollOffset += current!.geometry!.scrollExtent + current = childBefore(current!) + } + return childScrollOffset + case .reverse: + var childScrollOffset: Float = 0.0 + var current = childAfter(child as! RenderSliver) + while current != nil { + childScrollOffset -= current!.geometry!.scrollExtent + current = childAfter(current!) + } + return childScrollOffset + } + } + + public override func childMainAxisPosition(_ child: RenderObject) -> Float { + let paintOffset = (child.parentData as! SliverPhysicalParentData).paintOffset + switch sliverConstraints.axis { + case .horizontal: + return paintOffset.dx + case .vertical: + return paintOffset.dy + } + } + + public override func childCrossAxisPosition(_ child: RenderObject) -> Float { + return 0.0 + } + + public override func performLayout() { + let constraints = sliverConstraints + + var offset: Float = 0 + var maxPaintExtent: Float = 0 + + var child = firstChild + + while child != nil { + let beforeOffsetPaintExtent = calculatePaintOffset( + constraints, + from: 0.0, + to: offset + ) + child!.layout( + constraints.copyWith( + scrollOffset: max(0.0, constraints.scrollOffset - offset), + precedingScrollExtent: offset + constraints.precedingScrollExtent, + overlap: max(0.0, constraints.overlap - beforeOffsetPaintExtent), + remainingPaintExtent: constraints.remainingPaintExtent + - beforeOffsetPaintExtent, + remainingCacheExtent: constraints.remainingCacheExtent + - calculateCacheOffset(constraints, from: 0.0, to: offset), + cacheOrigin: min(0.0, constraints.cacheOrigin + offset) + ), + parentUsesSize: true + ) + let childLayoutGeometry = child!.geometry! + let childParentData = child!.parentData as! SliverPhysicalParentData + childParentData.paintOffset = + switch constraints.axis { + case .vertical: Offset(0.0, beforeOffsetPaintExtent) + case .horizontal: Offset(beforeOffsetPaintExtent, 0.0) + } + offset += childLayoutGeometry.scrollExtent + maxPaintExtent += child!.geometry!.maxPaintExtent + child = childAfter(child!) + assert { + if child != nil && maxPaintExtent.isInfinite { + preconditionFailure( + "Unreachable sliver found, you may have a sliver following " + + "a sliver with an infinite extent. " + ) + } + return true + } + } + + let totalScrollExtent = offset + offset = 0.0 + child = firstChild + // Second pass to correct out of bound paintOffsets. + while child != nil { + let beforeOffsetPaintExtent = calculatePaintOffset( + constraints, + from: 0.0, + to: offset + ) + let childLayoutGeometry = child!.geometry! + let childParentData = child!.parentData as! SliverPhysicalParentData + let remainingExtent = totalScrollExtent - constraints.scrollOffset + if childLayoutGeometry.paintExtent > remainingExtent { + let paintCorrection = childLayoutGeometry.paintExtent - remainingExtent + childParentData.paintOffset = + switch constraints.axis { + case .vertical: + Offset(0.0, beforeOffsetPaintExtent - paintCorrection) + case .horizontal: + Offset(beforeOffsetPaintExtent - paintCorrection, 0.0) + } + } + offset += child!.geometry!.scrollExtent + child = childAfter(child!) + } + + let paintExtent = calculatePaintOffset( + constraints, + from: min(constraints.scrollOffset, 0), + to: totalScrollExtent + ) + let cacheExtent = calculateCacheOffset( + constraints, + from: min(constraints.scrollOffset, 0), + to: totalScrollExtent + ) + geometry = SliverGeometry( + scrollExtent: totalScrollExtent, + paintExtent: paintExtent, + maxPaintExtent: maxPaintExtent, + hasVisualOverflow: totalScrollExtent > constraints.remainingPaintExtent + || constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent + ) + } + + public override func paint(context: PaintingContext, offset: Offset) { + let constraints = sliverConstraints + + if firstChild == nil { + return + } + // offset is to the top-left corner, regardless of our axis direction. + // originOffset gives us the delta from the real origin to the origin in the axis direction. + let mainAxisUnit: Offset + let crossAxisUnit: Offset + let originOffset: Offset + let addExtent: Bool + switch applyGrowthDirectionToAxisDirection( + constraints.axisDirection, + constraints.growthDirection + ) { + case .up: + mainAxisUnit = Offset(0.0, -1.0) + crossAxisUnit = Offset(1.0, 0.0) + originOffset = offset + Offset(0.0, geometry!.paintExtent) + addExtent = true + case .right: + mainAxisUnit = Offset(1.0, 0.0) + crossAxisUnit = Offset(0.0, 1.0) + originOffset = offset + addExtent = false + case .down: + mainAxisUnit = Offset(0.0, 1.0) + crossAxisUnit = Offset(1.0, 0.0) + originOffset = offset + addExtent = false + case .left: + mainAxisUnit = Offset(-1.0, 0.0) + crossAxisUnit = Offset(0.0, 1.0) + originOffset = offset + Offset(geometry!.paintExtent, 0.0) + addExtent = true + } + + var child = lastChild + while child != nil { + let mainAxisDelta = childMainAxisPosition(child!) + let crossAxisDelta = childCrossAxisPosition(child!) + var childOffset = Offset( + originOffset.dx + mainAxisUnit.dx * mainAxisDelta + crossAxisUnit.dx + * crossAxisDelta, + originOffset.dy + mainAxisUnit.dy * mainAxisDelta + crossAxisUnit.dy + * crossAxisDelta + ) + if addExtent { + childOffset = childOffset + mainAxisUnit * child!.geometry!.paintExtent + } + + if child!.geometry!.visible { + context.paintChild(child!, offset: childOffset) + } + child = childBefore(child!) + } + } + + public override func applyPaintTransform(_ child: RenderObject, transform: inout Matrix4x4f) { + let childParentData = child.parentData as! SliverPhysicalParentData + childParentData.applyPaintTransform(&transform) + } + + public override func hitTestChildren( + _ result: SliverHitTestResult, + mainAxisPosition: Float, + crossAxisPosition: Float + ) -> Bool { + var child = firstChild + while child != nil { + let isHit = result.addWithAxisOffset( + paintOffset: nil, + mainAxisOffset: childMainAxisPosition(child!), + crossAxisOffset: childCrossAxisPosition(child!), + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + hitTest: child!.hitTest + ) + if isHit { + return true + } + child = childAfter(child!) + } + return false + } + + // public override func visitChildrenForSemantics(_ visitor: RenderObjectVisitor) { + // var child = firstChild + // while child != nil { + // if child!.geometry!.visible { + // visitor(child!) + // } + // child = childAfter(child!) + // } + // } +} diff --git a/Sources/Shaft/Rendering/RenderViewport.swift b/Sources/Shaft/Rendering/RenderViewport.swift index 8bf305d..b02cc1b 100644 --- a/Sources/Shaft/Rendering/RenderViewport.swift +++ b/Sources/Shaft/Rendering/RenderViewport.swift @@ -372,6 +372,9 @@ public class RenderViewportBase: Rend /// * [cacheExtentStyle], which controls the units of the [cacheExtent]. public var cacheExtent: Float? { didSet { + if cacheExtent == nil { + cacheExtent = defaultCacheExtent + } if cacheExtent != oldValue { markNeedsLayout() } diff --git a/Sources/Shaft/Rendering/ViewportOffset.swift b/Sources/Shaft/Rendering/ViewportOffset.swift index 22e5f97..8b400ec 100644 --- a/Sources/Shaft/Rendering/ViewportOffset.swift +++ b/Sources/Shaft/Rendering/ViewportOffset.swift @@ -197,7 +197,7 @@ extension ViewportOffset { /// like [ScrollPosition] handle it by adjusting [to] to prevent over or /// underscroll. public func moveTo( - to: Float, + _ to: Float, duration: Duration? = nil, curve: Curve? = nil, clamp: Bool? = nil diff --git a/Sources/Shaft/Widgets/Framework/Framework.swift b/Sources/Shaft/Widgets/Framework/Framework.swift index fff681d..239ba8a 100644 --- a/Sources/Shaft/Widgets/Framework/Framework.swift +++ b/Sources/Shaft/Widgets/Framework/Framework.swift @@ -2508,26 +2508,26 @@ public class ComponentElement: Element { } /// An [Element] that uses a [StatelessWidget] as its configuration. -class StatelessElement: ComponentElement { - override init(_ widget: Widget) { +public class StatelessElement: ComponentElement { + public override init(_ widget: Widget) { assert(widget is StatelessWidget) super.init(widget) } - override func update(_ newWidget: Widget) { + public override func update(_ newWidget: Widget) { super.update(newWidget) assert(widget === newWidget) rebuild(force: true) } - override func build() -> Widget { + public override func build() -> Widget { return (widget as! StatelessWidget).build(context: self) } } /// An [Element] that uses a [StatefulWidget] as its configuration. -class StatefulElement: ComponentElement { - init(_ widget: T) { +public class StatefulElement: ComponentElement { + public init(_ widget: T) { super.init(widget) state = widget.createState() assert(state.element == nil) @@ -2541,13 +2541,13 @@ class StatefulElement: ComponentElement { /// There is a one-to-one relationship between [State] objects and the /// [StatefulElement] objects that hold them. The [State] objects are created /// by [StatefulElement] in [mount]. - var state: State! + public private(set) var state: State! - override func build() -> Widget { + public override func build() -> Widget { state.build(context: self) } - override func firstBuild() { + public override func firstBuild() { assert { state.debugLifecycleState = .created return true @@ -2565,7 +2565,7 @@ class StatefulElement: ComponentElement { super.firstBuild() } - override func performRebuild() { + public override func performRebuild() { if _didChangeDependencies { state.didChangeDependencies() _didChangeDependencies = false @@ -2573,7 +2573,7 @@ class StatefulElement: ComponentElement { super.performRebuild() } - override func update(_ newWidget: Widget) { + public override func update(_ newWidget: Widget) { super.update(newWidget) assert(widget === newWidget) let oldWidget = state.widget! @@ -2582,24 +2582,22 @@ class StatefulElement: ComponentElement { rebuild(force: true) } - // @override - // void activate() { - // super.activate(); - // state.activate(); - // // Since the State could have observed the deactivate() and thus disposed of - // // resources allocated in the build method, we have to rebuild the widget - // // so that its State can reallocate its resources. - // assert(_lifecycleState == _ElementLifecycle.active); // otherwise markNeedsBuild is a no-op - // markNeedsBuild(); - // } + public override func activate() { + super.activate() + state.activate() + // Since the State could have observed the deactivate() and thus disposed of + // resources allocated in the build method, we have to rebuild the widget + // so that its State can reallocate its resources. + assert(lifecycleState == .active) + markNeedsBuild() + } - // @override - // void deactivate() { - // state.deactivate(); - // super.deactivate(); - // } + public override func deactivate() { + state.deactivate() + super.deactivate() + } - override func unmount() { + public override func unmount() { super.unmount() state.dispose() assert(state.debugLifecycleState == .defunct) @@ -2620,7 +2618,7 @@ class StatefulElement: ComponentElement { /// to [didChangeDependencies] set it to true. private var _didChangeDependencies = false - override func didChangeDependencies() { + public override func didChangeDependencies() { super.didChangeDependencies() _didChangeDependencies = true } @@ -2628,16 +2626,16 @@ class StatefulElement: ComponentElement { /// An [Element] that uses a [ProxyWidget] as its configuration. public class ProxyElement: ComponentElement { - override init(_ widget: Widget) { + public override init(_ widget: Widget) { assert(widget is ProxyWidget) super.init(widget) } - override public func build() -> Widget { + public override func build() -> Widget { return (widget as! ProxyWidget).child } - override public func update(_ newWidget: Widget) { + public override func update(_ newWidget: Widget) { let oldWidget = widget as! ProxyWidget assert(widget !== newWidget) super.update(newWidget) @@ -2715,7 +2713,7 @@ public class ParentDataElement: ProxyElement { applyParentData(newWidget) } - override public func notifyClients(_ oldWidget: ProxyWidget) { + public override func notifyClients(_ oldWidget: ProxyWidget) { let widget = widget as! any ParentDataWidget applyParentData(widget) } diff --git a/Sources/Shaft/Widgets/Scroll/ScrollPositionWithSingleContext.swift b/Sources/Shaft/Widgets/Scroll/ScrollPositionWithSingleContext.swift index 25a2349..d1b1430 100644 --- a/Sources/Shaft/Widgets/Scroll/ScrollPositionWithSingleContext.swift +++ b/Sources/Shaft/Widgets/Scroll/ScrollPositionWithSingleContext.swift @@ -13,7 +13,7 @@ /// single [ScrollContext], such as a [Scrollable]. An instance of this class /// manages [ScrollActivity] instances, which change what content is visible in /// the [Scrollable]'s [Viewport]. -class ScrollPositionWithSingleContext: ScrollPosition { +public class ScrollPositionWithSingleContext: ScrollPosition { public init( physics: ScrollPhysics, context: ScrollContext, @@ -68,7 +68,7 @@ class ScrollPositionWithSingleContext: ScrollPosition { } private var _userScrollDirection: ScrollDirection = .idle - override var userScrollDirection: ScrollDirection { + public override var userScrollDirection: ScrollDirection { _userScrollDirection } @@ -83,7 +83,19 @@ class ScrollPositionWithSingleContext: ScrollPosition { // didUpdateScrollDirection(value) } - override func pointerScroll(_ delta: Float) { + public override func jumpTo(_ value: Float) { + goIdle() + if pixels != value { + // let oldPixels = pixels + forcePixels(value) + // didStartScroll() + // didUpdateScrollPositionBy(pixels - oldPixels) + // didEndScroll() + } + goBallistic(0.0) + } + + public override func pointerScroll(_ delta: Float) { // If an update is made to pointer scrolling here, consider if the same // (or similar) change should be made in // _NestedScrollCoordinator.pointerScroll. @@ -111,7 +123,7 @@ class ScrollPositionWithSingleContext: ScrollPosition { } } - override var axisDirection: AxisDirection { + public override var axisDirection: AxisDirection { context.axisDirection } } diff --git a/Sources/Shaft/Widgets/Scroll/Scrollable.swift b/Sources/Shaft/Widgets/Scroll/Scrollable.swift index d8e71fd..a68ba4e 100644 --- a/Sources/Shaft/Widgets/Scroll/Scrollable.swift +++ b/Sources/Shaft/Widgets/Scroll/Scrollable.swift @@ -231,7 +231,7 @@ public final class Scrollable: StatefulWidget { /// to [ScrollView.clipBehavior] and is supplied to the [Viewport]. public let clipBehavior: Clip - public func createState() -> State { + public func createState() -> ScrollableState { ScrollableState() } diff --git a/Sources/Shaft/Widgets/ScrollView.swift b/Sources/Shaft/Widgets/ScrollView.swift index d3fe185..03c50f4 100644 --- a/Sources/Shaft/Widgets/ScrollView.swift +++ b/Sources/Shaft/Widgets/ScrollView.swift @@ -545,15 +545,15 @@ public class CustomScrollView: ScrollViewBase { center: (any Key)? = nil, anchor: Float = 0.0, cacheExtent: Float? = nil, - slivers: [Widget] = [], semanticChildCount: Int? = nil, dragStartBehavior: DragStartBehavior = .start, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior = .manual, restorationId: String? = nil, clipBehavior: Clip = .hardEdge, - hitTestBehavior: HitTestBehavior = .opaque + hitTestBehavior: HitTestBehavior = .opaque, + @WidgetListBuilder slivers: () -> [Widget] ) { - self.slivers = slivers + self.slivers = slivers() super.init( key: key, scrollDirection: scrollDirection, diff --git a/Sources/Shaft/Widgets/Sliver.swift b/Sources/Shaft/Widgets/Sliver.swift index 13a0052..43204f3 100644 --- a/Sources/Shaft/Widgets/Sliver.swift +++ b/Sources/Shaft/Widgets/Sliver.swift @@ -171,16 +171,16 @@ public class SliverList: SliverMultiBoxAdaptorWidget { /// ) /// /// {@end-tool} - public convenience init( - builder key: (any Key)? = nil, - itemBuilder: @escaping NullableIndexedWidgetBuilder, - findChildIndexCallback: ChildIndexGetter? = nil, + public static func builder( + key: (any Key)? = nil, itemCount: Int? = nil, addAutomaticKeepAlives: Bool = true, addRepaintBoundaries: Bool = true, - addSemanticIndexes: Bool = true - ) { - self.init( + addSemanticIndexes: Bool = true, + itemBuilder: @escaping NullableIndexedWidgetBuilder, + findChildIndexCallback: ChildIndexGetter? = nil + ) -> SliverList { + .init( key: key, delegate: SliverChildBuilderDelegate( itemBuilder, @@ -302,15 +302,15 @@ public class SliverList: SliverMultiBoxAdaptorWidget { /// {@end-tool} public static func list( key: (any Key)? = nil, - children: [Widget], addAutomaticKeepAlives: Bool = true, addRepaintBoundaries: Bool = true, - addSemanticIndexes: Bool = true + addSemanticIndexes: Bool = true, + @WidgetBuilder children: () -> [Widget] ) -> SliverList { .init( key: key, delegate: SliverChildListDelegate( - children, + children(), addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, addSemanticIndexes: addSemanticIndexes @@ -1230,3 +1230,129 @@ public class SliverMultiBoxAdaptorElement: RenderObjectElement, RenderSliverBoxC // }.forEach(visitor) // } } + +/// A sliver that places multiple sliver children in a linear array along +/// the cross axis. +/// +/// ## Layout algorithm +/// +/// _This section describes how the framework causes [RenderSliverCrossAxisGroup] +/// to position its children._ +/// +/// Layout for a [RenderSliverCrossAxisGroup] has four steps: +/// +/// 1. Layout each child with a null or zero flex factor with cross axis constraint +/// being whatever cross axis space is remaining after laying out any previous +/// sliver. Slivers with null or zero flex factor should determine their own +/// [SliverGeometry.crossAxisExtent]. For example, the [SliverConstrainedCrossAxis] +/// widget uses either [SliverConstrainedCrossAxis.maxExtent] or +/// [SliverConstraints.crossAxisExtent], deciding between whichever is smaller. +/// 2. Divide up the remaining cross axis space among the children with non-zero flex +/// factors according to their flex factor. For example, a child with a flex +/// factor of 2.0 will receive twice the amount of cross axis space as a child +/// with a flex factor 1.0. +/// 3. Layout each of the remaining children with the cross axis constraint +/// allocated in the previous step. +/// 4. Set the geometry to that of whichever child has the longest +/// [SliverGeometry.scrollExtent] with the [SliverGeometry.crossAxisExtent] adjusted +/// to [SliverConstraints.crossAxisExtent]. +/// +/// {@tool dartpad} +/// In this sample the [SliverCrossAxisGroup] sizes its three [children] so that +/// the first normal [SliverList] has a flex factor of 1, the second [SliverConstrainedCrossAxis] +/// has a flex factor of 0 and a maximum cross axis extent of 200.0, and the third +/// [SliverCrossAxisExpanded] has a flex factor of 2. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SliverCrossAxisExpanded], which is the [ParentDataWidget] for setting a flex +/// value to a widget. +/// * [SliverConstrainedCrossAxis], which is a [RenderObjectWidget] for setting +/// an extent to constrain the widget to. +/// * [SliverMainAxisGroup], which is the [RenderObjectWidget] for laying out +/// multiple slivers along the main axis. +/// A sliver that places multiple sliver children in a linear array along +/// the cross axis. +public class SliverCrossAxisGroup: MultiChildRenderObjectWidget { + /// Creates a sliver that places sliver children in a linear array along + /// the cross axis. + public init( + key: (any Key)? = nil, + slivers: [Widget] + ) { + self.key = key + self.children = slivers + } + + public let key: (any Key)? + public var children: [any Widget] + + public func createRenderObject(context: BuildContext) -> RenderSliverCrossAxisGroup { + return RenderSliverCrossAxisGroup() + } +} + +/// A sliver that places multiple sliver children in a linear array along +/// the main axis, one after another. +/// +/// ## Layout algorithm +/// +/// _This section describes how the framework causes [RenderSliverMainAxisGroup] +/// to position its children._ +/// +/// Layout for a [RenderSliverMainAxisGroup] has four steps: +/// +/// 1. Keep track of an offset variable which is the total [SliverGeometry.scrollExtent] +/// of the slivers laid out so far. +/// 2. To determine the constraints for the next sliver child to layout, calculate the +/// amount of paint extent occupied from 0.0 to the offset variable and subtract this from +/// [SliverConstraints.remainingPaintExtent] minus to use as the child's +/// [SliverConstraints.remainingPaintExtent]. For the [SliverConstraints.scrollOffset], +/// take the provided constraint's value and subtract out the offset variable, using +/// 0.0 if negative. +/// 3. Once we finish laying out all the slivers, this offset variable represents +/// the total [SliverGeometry.scrollExtent] of the sliver group. Since it is possible +/// for specialized slivers to try to paint itself outside of the bounds of the +/// sliver group's scroll extent (see [SliverPersistentHeader]), we must do a +/// second pass to set a [SliverPhysicalParentData.paintOffset] to make sure it +/// is within the bounds of the sliver group. +/// 4. Finally, set the [RenderSliverMainAxisGroup.geometry] with the total +/// [SliverGeometry.scrollExtent], [SliverGeometry.paintExtent] calculated from +/// the constraints and [SliverGeometry.scrollExtent], and [SliverGeometry.maxPaintExtent]. +/// +/// {@tool dartpad} +/// In this sample the [CustomScrollView] renders a [SliverMainAxisGroup] and a +/// [SliverToBoxAdapter] with some content. The [SliverMainAxisGroup] renders a +/// [SliverAppBar], [SliverList], and [SliverToBoxAdapter]. Notice that when the +/// [SliverMainAxisGroup] goes out of view, so does the pinned [SliverAppBar]. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_main_axis_group.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SliverPersistentHeader], which is a [RenderObjectWidget] which may require +/// adjustment to its [SliverPhysicalParentData.paintOffset] to make it fit +/// within the computed [SliverGeometry.scrollExtent] of the [SliverMainAxisGroup]. +/// * [SliverCrossAxisGroup], which is the [RenderObjectWidget] for laying out +/// multiple slivers along the cross axis. +public class SliverMainAxisGroup: MultiChildRenderObjectWidget { + public init( + key: (any Key)? = nil, + @WidgetListBuilder children: () -> [Widget] + ) { + self.key = key + self.children = children() + } + + public var key: (any Key)? + + public var children: [any Widget] + + public func createRenderObject(context: any BuildContext) -> some RenderObject { + return RenderSliverMainAxisGroup() + } +} diff --git a/Sources/Shaft/Widgets/SliverPinnedHeader.swift b/Sources/Shaft/Widgets/SliverPinnedHeader.swift new file mode 100644 index 0000000..cf05bda --- /dev/null +++ b/Sources/Shaft/Widgets/SliverPinnedHeader.swift @@ -0,0 +1,96 @@ +/// A sliver that keeps its Widget child at the top of the a [CustomScrollView]. +/// +/// This sliver is preferable to the general purpose [SliverPersistentHeader] +/// for its relatively narrow use case because there's no need to create a +/// [SliverPersistentHeaderDelegate] or to predict the header's size. +/// +/// {@tool dartpad} +/// This example demonstrates that the sliver's size can change. Pressing the +/// floating action button replaces the one line of header text with two lines. +/// +/// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// A more elaborate example which creates an app bar that's similar to the one +/// that appears in the iOS Settings app. In this example the pinned header +/// starts out transparent and the first item in the list serves as the app's +/// "Settings" title. When the title item has been scrolled completely behind +/// the pinned header, the header animates its opacity from 0 to 1 and its +/// (centered) "Settings" title appears. The fact that the header's opacity +/// depends on the height of the title item - which is unknown until the list +/// has been laid out - necessitates monitoring the title item's +/// [SliverGeometry.scrollExtent] and the header's [SliverConstraints.scrollOffset] +/// from a scroll [NotificationListener]. See the source code for more details. +/// +/// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SliverResizingHeader] - which similarly pins the header at the top +/// of the [CustomScrollView] but reacts to scrolling by resizing the header +/// between its minimum and maximum extent limits. +/// * [SliverFloatingHeader] - which animates the header in and out of view +/// in response to downward and upwards scrolls. +/// * [SliverPersistentHeader] - a general purpose header that can be +/// configured as a pinned, resizing, or floating header. +public class SliverPinnedHeader: SingleChildRenderObjectWidget { + /// Creates a sliver whose [Widget] child appears at the top of a + /// [CustomScrollView]. + public init( + key: (any Key)? = nil, + @WidgetBuilder child: () -> Widget + ) { + self.key = key + self.child = child() + } + + public let key: (any Key)? + + public let child: (any Widget)? + + public func createRenderObject(context: BuildContext) -> RenderObject { + return _RenderSliverPinnedHeader() + } +} + +class _RenderSliverPinnedHeader: RenderSliverSingleBoxAdapter { + var childExtent: Float { + if child == nil { + return 0.0 + } + assert(child!.hasSize) + return switch sliverConstraints.axis { + case .vertical: + child!.size.height + case .horizontal: + child!.size.width + } + } + + override func childMainAxisPosition(_ child: RenderObject) -> Float { + return 0 + } + + override func performLayout() { + let constraints = sliverConstraints + child?.layout(constraints.asBoxConstraints(), parentUsesSize: true) + + let layoutExtent = + (childExtent - constraints.scrollOffset).clamped( + to: 0...constraints.remainingPaintExtent + ) + let paintExtent = min(childExtent, constraints.remainingPaintExtent - constraints.overlap) + geometry = SliverGeometry( + scrollExtent: childExtent, + paintExtent: paintExtent, + paintOrigin: constraints.overlap, + layoutExtent: layoutExtent, + maxPaintExtent: childExtent, + maxScrollObstructionExtent: childExtent, + hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. + cacheExtent: calculateCacheOffset(constraints, from: 0.0, to: childExtent) + ) + } +} diff --git a/Sources/Shaft/Widgets/Text/Text.swift b/Sources/Shaft/Widgets/Text/Text.swift index 779f36d..981a93f 100644 --- a/Sources/Shaft/Widgets/Text/Text.swift +++ b/Sources/Shaft/Widgets/Text/Text.swift @@ -54,24 +54,24 @@ public class Text: StatelessWidget { /// The text to display. /// /// This will be null if a [textSpan] is provided instead. - let data: String? + public let data: String? /// The text to display as a [InlineSpan]. /// /// This will be null if [data] is provided instead. - let textSpan: InlineSpan? + public let textSpan: InlineSpan? /// If non-null, the style to use for this text. /// /// If the style's "inherit" property is true, the style will be merged with /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will /// replace the closest enclosing [DefaultTextStyle]. - let style: TextStyle? + public let style: TextStyle? - let strutStyle: StrutStyle? + public let strutStyle: StrutStyle? /// How the text should be aligned horizontally. - let textAlign: TextAlign? + public let textAlign: TextAlign? /// The directionality of the text. /// @@ -86,7 +86,7 @@ public class Text: StatelessWidget { /// its left. /// /// Defaults to the ambient [Directionality], if any. - let textDirection: TextDirection? + public let textDirection: TextDirection? /// Used to select a font when the same Unicode character can /// be rendered differently, depending on the locale. @@ -95,21 +95,21 @@ public class Text: StatelessWidget { /// is inherited from the enclosing app with `Localizations.localeOf(context)`. /// /// See [RenderParagraph.locale] for more information. - // let locale: Locale? + // public let locale: Locale? /// Whether the text should break at soft line breaks. /// /// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space. - let softWrap: Bool? + public let softWrap: Bool? /// How visual overflow should be handled. /// /// If this is null [TextStyle.overflow] will be used, otherwise the value /// from the nearest [DefaultTextStyle] ancestor will be used. - let overflow: TextOverflow? + public let overflow: TextOverflow? /// {@macro flutter.painting.textPainter.textScaler} - let textScaler: (any TextScaler)? + public let textScaler: (any TextScaler)? /// An optional maximum number of lines for the text to span, wrapping if necessary. /// If the text exceeds the given number of lines, it will be truncated according @@ -122,18 +122,18 @@ public class Text: StatelessWidget { /// an explicit number for its [DefaultTextStyle.maxLines], then the /// [DefaultTextStyle] value will take precedence. You can use a [RichText] /// widget directly to entirely override the [DefaultTextStyle]. - let maxLines: Int? + public let maxLines: Int? /// An alternative semantics label for this text. /// /// If present, the semantics of this widget will contain this value instead /// of the actual text. This will overwrite any of the semantics labels applied /// directly to the [TextSpan]s. - let semanticsLabel: String? + public let semanticsLabel: String? - let textWidthBasis: TextWidthBasis? + public let textWidthBasis: TextWidthBasis? - let textHeightBehavior: TextHeightBehavior? + public let textHeightBehavior: TextHeightBehavior? /// The color to use when painting the selection. /// @@ -143,7 +143,7 @@ public class Text: StatelessWidget { /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] /// (semi-transparent grey). - let selectionColor: Color? + public let selectionColor: Color? public func build(context: BuildContext) -> Widget { let defaultTextStyle = DefaultTextStyle.of(context) diff --git a/Tests/ShaftTests/TestUtils/WidgetTester.swift b/Tests/ShaftTests/TestUtils/WidgetTester.swift index 1c84c79..e9ae2e3 100644 --- a/Tests/ShaftTests/TestUtils/WidgetTester.swift +++ b/Tests/ShaftTests/TestUtils/WidgetTester.swift @@ -54,7 +54,7 @@ class WidgetTester { /// Attaches the provided `Widget` to the root of the widget tree, and /// schedules a frame to be rendered. - func pumpWidget(_ widget: Widget) { + public func pumpWidget(_ widget: Widget) { WidgetsBinding.shared.attachRootWidget( View( view: implicitView, @@ -68,12 +68,27 @@ class WidgetTester { forceFrame() } + /// Forces the rendering of a frame. + public func forceFrame() { + backend.onBeginFrame?(.zero) + backend.onDrawFrame?() + } + + // public func pumpAndSettle() { + // forceFrame() + // while backend.activeTimers.count > 0 { + // backend.elapse(.zero) + // } + // } + + // MARK:- FINDERS + /// Returns a sequence of all the elements in the widget tree. /// /// This property provides a way to iterate over all the elements in the /// widget tree, which can be useful for testing and debugging purposes. The /// sequence is generated from the root element of the widget tree. - var allElements: ElementSequence { + public var allElements: ElementSequence { ElementSequence(WidgetsBinding.shared.rootElement!) } @@ -81,7 +96,7 @@ class WidgetTester { /// /// This method iterates through all the elements in the widget tree and /// returns the first widget that is an instance of the specified type `T`. - func findWidget(_ type: T.Type) -> T? { + public func findWidget(_ type: T.Type) -> T? { for element in allElements { if let widget = element.widget as? T { return widget @@ -90,12 +105,26 @@ class WidgetTester { return nil } + /// Finds the first State object of the given type in the widget tree. + /// + /// This method iterates through all the elements in the widget tree and + /// returns the first State object associated with a StatefulWidget that is + /// an instance of the specified type `T`. + public func findState(_ type: T.Type) -> T.StateType? { + for element in allElements { + if let statefulElement = element as? StatefulElement { + return statefulElement.state as? T.StateType + } + } + return nil + } + /// Finds all widgets of the given type in the widget tree. /// /// This method iterates through all the elements in the widget tree and /// returns an array of all widgets that are instances of the specified type /// `T`. - func findWidgets(_ type: T.Type) -> [T] { + public func findWidgets(_ type: T.Type) -> [T] { var result: [T] = [] for element in allElements { if let widget = element.widget as? T { @@ -105,10 +134,204 @@ class WidgetTester { return result } - /// Forces the rendering of a frame. - func forceFrame() { - backend.onBeginFrame?(.zero) - backend.onDrawFrame?() + /// Returns the first element that matches the given finder. If no matching + /// element is found, this method will throw an error. + public func match(_ finder: Finder) -> Element { + for element in allElements { + if finder.matches(candidate: element) { + return element + } + } + fatalError("The finder \"\(finder)\" could not find any matching widgets.") + } + + /// Returns all elements that match the given finder. + public func matchAll(_ finder: Finder) -> [Element] { + var result: [Element] = [] + for element in allElements { + if finder.matches(candidate: element) { + result.append(element) + } + } + return result + } + + // MARK:- GEOMETRY + + private func getElementPoint( + finder: Finder, + sizeToPoint: (Shaft.Size) -> Offset + ) -> Offset { + let element = match(finder) + let renderObject = element.renderObject + if renderObject == nil { + fatalError( + "The finder \"\(finder)\" found an element, but it does not have a corresponding render object. Maybe the element has not yet been rendered?" + ) + } + if !(renderObject is RenderBox) { + fatalError( + "The finder \"\(finder)\" found an element whose corresponding render object is not a RenderBox (it is a \(type(of: renderObject))" + ) + } + let box = element.renderObject as! RenderBox + let location = box.localToGlobal(sizeToPoint(box.size)) + return location + } + + /// Returns the point at the center of the given widget. + /// + /// {@template flutter.flutter_test.WidgetController.getCenter.warnIfMissed} + /// If `warnIfMissed` is true (the default is false), then the returned + /// coordinate is checked to see if a hit test at the returned location would + /// actually include the specified element in the [HitTestResult], and if not, + /// a warning is printed to the console. + /// + /// The `callee` argument is used to identify the method that should be + /// referenced in messages regarding `warnIfMissed`. It can be ignored unless + /// this method is being called from another that is forwarding its own + /// `warnIfMissed` parameter (see e.g. the implementation of [tap]). + /// {@endtemplate} + public func getCenter(_ finder: Finder) -> Offset { + return getElementPoint(finder: finder) { size in + size.center(origin: Offset.zero) + } + } + + /// Returns the point at the top left of the given widget. + public func getTopLeft(_ finder: Finder) -> Offset { + return getElementPoint(finder: finder) { _ in + Offset.zero + } + } + + /// Returns the point at the top right of the given widget. This + /// point is not inside the object's hit test area. + public func getTopRight(_ finder: Finder) -> Offset { + return getElementPoint(finder: finder) { size in + size.topRight(origin: Offset.zero) + } + } + + /// Returns the point at the bottom left of the given widget. This + /// point is not inside the object's hit test area. + public func getBottomLeft(_ finder: Finder) -> Offset { + return getElementPoint(finder: finder) { size in + size.bottomLeft(origin: Offset.zero) + } + } + + /// Returns the point at the bottom right of the given widget. This + /// point is not inside the object's hit test area. + public func getBottomRight(_ finder: Finder) -> Offset { + return getElementPoint(finder: finder) { size in + size.bottomRight(origin: Offset.zero) + } + } + + /// Returns the rect of the given widget. This is only valid once + /// the widget's render object has been laid out at least once. + public func getRect(_ finder: Finder) -> Shaft.Rect { + let topLeft = getTopLeft(finder) + let bottomRight = getBottomRight(finder) + return .fromPoints(topLeft, bottomRight) + } + + /// Returns the size of the given widget. This is only valid once + /// the widget's render object has been laid out at least once. + public func getSize(_ finder: Finder) -> Shaft.Size { + let element = match(finder) + let box = element.renderObject as! RenderBox + return box.size + } +} + +public protocol Finder { + func matches(candidate: Element) -> Bool +} + +public class _MatchTextFinder: Finder { + public init(findRichText: Bool = false) { + self.findRichText = findRichText + } + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + let findRichText: Bool + + func matchesText(_ textToMatch: String) -> Bool { + fatalError("Must be implemented by subclass") + } + + public func matches(candidate: Element) -> Bool { + let widget = candidate.widget! + if widget is EditableText { + return _matchesEditableText(widget as! EditableText) + } + + if !findRichText { + return _matchesNonRichText(widget) + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget) + } + + func _matchesRichText(_ widget: Widget) -> Bool { + if let richText = widget as? RichText { + return matchesText(richText.text.toPlainText()) + } + return false + } + + func _matchesNonRichText(_ widget: Widget) -> Bool { + if let text = widget as? Text { + if let data = text.data { + return matchesText(data) + } + assert(text.textSpan != nil) + return matchesText(text.textSpan!.toPlainText()) + } + return false + } + + func _matchesEditableText(_ widget: EditableText) -> Bool { + return matchesText(widget.controller.text) + } +} + +public class _TextWidgetFinder: _MatchTextFinder { + init(text: String, findRichText: Bool = false) { + self.text = text + super.init(findRichText: findRichText) + } + + let text: String + + var description: String { + return "text \"\(text)\"" + } + + override func matchesText(_ textToMatch: String) -> Bool { + return textToMatch == text + } +} + +extension Finder where Self == _TextWidgetFinder { + static func text(_ text: String, findRichText: Bool = false) -> _TextWidgetFinder { + return _TextWidgetFinder(text: text, findRichText: findRichText) } } diff --git a/Tests/ShaftTests/Widgets/SliverPinnedHeaderTest.swift b/Tests/ShaftTests/Widgets/SliverPinnedHeaderTest.swift new file mode 100644 index 0000000..188061d --- /dev/null +++ b/Tests/ShaftTests/Widgets/SliverPinnedHeaderTest.swift @@ -0,0 +1,152 @@ +import Foundation +import Shaft +import XCTest + +class SliverPinnedHeaderTest: XCTestCase { + func test_SliverPinnedHeader_basics() { + testWidgets { tester in + func buildFrame(axis: Axis, reverse: Bool) -> Widget { + CustomScrollView( + scrollDirection: axis, + reverse: reverse + ) { + SliverPinnedHeader { + Text("PinnedHeaderSliver") + } + SliverList( + delegate: SliverChildBuilderDelegate( + { context, index in Text("Item \(index)") }, + childCount: 100 + ) + ) + } + } + + func getHeaderRect() -> TRect { + return tester.getRect(.text("PinnedHeaderSliver")) + } + func getItemRect(_ index: Int) -> TRect { + return tester.getRect(.text("Item \(index)")) + } + + // axis: Axis.vertical, reverse: false + do { + tester.pumpWidget(buildFrame(axis: .vertical, reverse: false)) + tester.forceFrame() + let position = tester.findState(Scrollable.self)!.position! + + // The test viewport is 800 x 600 (width x height). + // The header's child is at the top of the scroll view and all items are the same height. + XCTAssertEqual(getHeaderRect().topLeft, .zero) + XCTAssertEqual(getHeaderRect().width, 800) + XCTAssertEqual( + getHeaderRect().height, + tester.getSize(.text("PinnedHeaderSliver")).height + ) + + // First and last visible items + let itemHeight = getItemRect(0).height + let visibleItemCount = Int(600 / itemHeight) - 1 // less 1 for the header + XCTAssertEqual(tester.matchAll(.text("Item 0")).count, 1) + XCTAssertEqual(tester.matchAll(.text("Item \(visibleItemCount - 1)")).count, 1) + + // Scrolling up and down leaves the header at the top. + position.moveTo(itemHeight * 5) + tester.forceFrame() + XCTAssertEqual(getHeaderRect().top, 0) + XCTAssertEqual(getHeaderRect().width, 800) + position.moveTo(itemHeight * -5) + XCTAssertEqual(getHeaderRect().top, 0) + XCTAssertEqual(getHeaderRect().width, 800) + } + + // axis: Axis.horizontal, reverse: false + do { + tester.pumpWidget(buildFrame(axis: .horizontal, reverse: false)) + tester.forceFrame() + let position = tester.findState(Scrollable.self)!.position! + + XCTAssertEqual(getHeaderRect().topLeft, .zero) + XCTAssertEqual(getHeaderRect().height, 600) + XCTAssertEqual( + getHeaderRect().width, + tester.getSize(.text("PinnedHeaderSliver")).width + ) + + // First and last visible items (assuming < 10 items visible) + let itemWidth = getItemRect(0).width + let visibleItemCount = Int((800 - getHeaderRect().width) / itemWidth) + XCTAssertEqual(tester.matchAll(.text("Item 0")).count, 1) + XCTAssertEqual(tester.matchAll(.text("Item \(visibleItemCount - 1)")).count, 1) + + // Scrolling left and right leaves the header on the left. + position.moveTo(itemWidth * 5) + tester.forceFrame() + XCTAssertEqual(getHeaderRect().left, 0) + XCTAssertEqual(getHeaderRect().height, 600) + position.moveTo(itemWidth * -5) + XCTAssertEqual(getHeaderRect().left, 0) + XCTAssertEqual(getHeaderRect().height, 600) + } + + // axis: Axis.vertical, reverse: true + do { + tester.pumpWidget(buildFrame(axis: .vertical, reverse: true)) + tester.forceFrame() + let position = tester.findState(Scrollable.self)!.position! + + XCTAssertEqual(getHeaderRect().bottomLeft, Offset(0, 600)) + XCTAssertEqual(getHeaderRect().width, 800) + XCTAssertEqual( + getHeaderRect().height, + tester.getSize(.text("PinnedHeaderSliver")).height + ) + + // First and last visible items + let itemHeight = getItemRect(0).height + let visibleItemCount = Int(600 / itemHeight) - 1 // less 1 for the header + XCTAssertEqual(tester.matchAll(.text("Item 0")).count, 1) + XCTAssertEqual(tester.matchAll(.text("Item \(visibleItemCount - 1)")).count, 1) + + // Scrolling up and down leaves the header at the bottom. + position.moveTo(itemHeight * 5) + tester.forceFrame() + XCTAssertEqual(getHeaderRect().bottomLeft, Offset(0, 600)) + XCTAssertEqual(getHeaderRect().width, 800) + position.moveTo(itemHeight * -5) + XCTAssertEqual(getHeaderRect().bottomLeft, Offset(0, 600)) + XCTAssertEqual(getHeaderRect().width, 800) + } + + // axis: Axis.horizontal, reverse: true + do { + tester.pumpWidget(buildFrame(axis: .horizontal, reverse: true)) + tester.forceFrame() + let position = tester.findState(Scrollable.self)!.position! + + XCTAssertEqual(getHeaderRect().topRight, Offset(800, 0)) + XCTAssertEqual(getHeaderRect().height, 600) + XCTAssertEqual( + getHeaderRect().width, + tester.getSize(.text("PinnedHeaderSliver")).width, + accuracy: 0.0001 + ) + + // First and last visible items (assuming < 10 items visible) + let itemWidth = getItemRect(0).width + let visibleItemCount = Int((800 - getHeaderRect().width) / itemWidth) + XCTAssertEqual(tester.matchAll(.text("Item 0")).count, 1) + XCTAssertEqual(tester.matchAll(.text("Item \(visibleItemCount - 1)")).count, 1) + + // Scrolling left and right leaves the header on the right. + position.moveTo(itemWidth * 5) + tester.forceFrame() + XCTAssertEqual(getHeaderRect().topRight, Offset(800, 0)) + XCTAssertEqual(getHeaderRect().height, 600) + position.moveTo(itemWidth * -5) + XCTAssertEqual(getHeaderRect().topRight, Offset(800, 0)) + XCTAssertEqual(getHeaderRect().height, 600) + } + } + } +} From 88c2824059fcdd15fbf42af5de40d40af78f43ff Mon Sep 17 00:00:00 2001 From: xuty Date: Mon, 3 Feb 2025 23:04:27 +0800 Subject: [PATCH 2/2] Implement child management methods in RawViewElement --- Sources/Shaft/Widgets/View.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/Shaft/Widgets/View.swift b/Sources/Shaft/Widgets/View.swift index ddac9d6..b3a6363 100644 --- a/Sources/Shaft/Widgets/View.swift +++ b/Sources/Shaft/Widgets/View.swift @@ -158,6 +158,20 @@ class RawViewElement: RenderObjectElement { renderObject.child = (child as! RenderBox) } + override func moveRenderObjectChild( + _ child: RenderObject, + oldSlot: (any Slot)?, + newSlot: (any Slot)? + ) { + assertionFailure() + } + + override func removeRenderObjectChild(_ child: RenderObject, slot: (any Slot)?) { + assert(slot == nil) + assert((renderObject as! RenderView).child === child) + (renderObject as! RenderView).child = nil + } + override func visitChildren(_ visitor: (Element) -> Void) { if let child = child { visitor(child)