我正在探索如何在 SwiftUI 中实现页面卷曲,类似于 UIKit 中使用 UIPageViewController 和 .pageCurl 过渡样式提供的功能。
let PagesCurl = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
在 SwiftUI 中,我希望实现一种卷页效果,用户可以用手指翻页,类似于 Apple 书店中看到的风格。这是我一直在研究的一个简化的 SwiftUI 示例:
struct Episode1View: View {
@State private var currentPage = 0
let contentCount = 11 // Number of content slices
var body: some View {
TabView(selection: $currentPage) {
ForEach(1...contentCount, id: \.self) { index in
BeforeThePage(imageName: "C1 Slice \(index)")
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarHidden(true)
.rotation3DEffect(
.degrees(currentPage == 0 ? 0 : 180),
axis: (x: 0, y: 1, z: 0),
anchor: .trailing,
perspective: 0.5
)
.animation(.default)
}
}
struct BeforeThePage: View {
let imageName: String
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}
如果您对在 SwiftUI 中实现逼真的页面卷曲效果有任何提示或见解,我将非常感谢您的意见。谢谢!
我在 SwiftUI、iOS 和 macOS 中有一个页面卷曲解决方案。
iPad 上的卷页:
页面卷曲要求很高,但我设法将复杂性隐藏在一系列视图修饰符中。
I) iOS 中显示可卷曲页面的代码:
struct ContentPage: View {
//Page Curling
@State var initiatePageCurl: Bool = false
@State var pageCurlForward: Bool = true
let totalDuration : Double = 1 // in seconds
var refreshPage : @MainActor () -> Void {
get {
return { @MainActor in
self.displayedObjectID = self.pageController.displayedObjectID
}
}
}
@State var displayedObjectID: DisplayableID = EmptyDisplayable().id
var body: some View {
let view = self.pageViewGenerator(displayedObjectID: self.displayedObjectID)
let windowContentView = AppDelegate.shared?.topViewController(documentID: self.document.interfaceID)?.view
let theView =
view
.pageCurlable(initiatePageCurl: self.$initiatePageCurl, curlForward: self.pageCurlForward,duration: self.totalDuration, view:windowContentView ,document:self.document, refreshPage: self.refreshPage)
.onChange(of: self.pageController.displayedObjectID, { oldValue, newValue in
if self.usePageCurl {
let oldPageNum = self.pageController.object(for: oldValue)?.pageNumber ?? 0
let newPageNum = self.pageController.object(for: newValue)?.pageNumber ?? 0
if oldPageNum <= newPageNum { self.pageCurlForward = true }
else { self.pageCurlForward = false }
self.initiatePageCurl.toggle()
}
else {
self.refreshPage()
}
})
return theView
}
修饰符 pageCurlable 有 5 个基本参数:触发器、卷曲方向、卷曲持续时间、显示当前页面的 UIView 和显示下一页的刷新页面闭包。在我的上下文中,我需要文档参数,因为文档设置定义了视图的背景。
页面ID的.onChange修饰符决定翻页是向前还是向后,并通过切换触发器来启动翻页。
pageCurling 修饰符在内部使用另外 2 个修饰符。第一个计算静态页面卷曲图像,第二个计算该静态图像的动画。
如何从 SwiftUI 视图获取 CIImage?根据我的理解,SwiftUI 视图只是如何获取可显示视图的方法。为了渲染,它们必须嵌入到 UIHostingView 中。当前显示的页面已嵌入 - 我传输前视图。对于未来视图(下一页),我使用特定的 PageCurlFutureView UIViewRepresentable。
这是 UIView 上获取 CIImage 的扩展:
func ciimage(rect viewRect: CGRect? = nil, pixelsPerPoint:CGFloat = 1) -> CIImage? {
let oldBounds = self.bounds
let rect = viewRect ?? oldBounds
self.bounds = CGRect(origin: rect.origin, size: rect.size )
let renderer = UIGraphicsImageRenderer(bounds:rect, format: UIGraphicsImageRenderer.standardImageFormat(scale:pixelsPerPoint))
let uiImage = renderer.image(actions: {(context) in
self.drawHierarchy(in: self.bounds, afterScreenUpdates: false)
})
self.bounds = oldBounds
if let cgImage = uiImage.cgImage {
let image = CIImage(cgImage: cgImage)
let imageExtent = image.extent
let scaleFactor = rect.width/imageExtent.width
let scaledImage : CIImage
if abs(scaleFactor - 1) > 0.1 {
let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
scaledImage = image.transformed(by: scaleTransform)
}
else { scaledImage = image }
return scaledImage
}
return nil
}
在 macOS 上,这是 NSView 的扩展:
func ciimage(rect viewRect: CGRect?) -> CIImage? {
let rect = viewRect ?? self.bounds
if let bitMap = self.bitmapImageRepForCachingDisplay(in: rect) {
self.cacheDisplay(in: rect, to: bitMap)
let ciImage = CIImage(bitmapImageRep: bitMap)
return ciImage
}
return nil
}
对于前向翻页,图像源是当前显示的 SwiftUI 视图。
II)视图修改器生成静态页面卷曲图像:
struct PageCurl: ViewModifier {
static var initialImage: CIImage? = nil {
didSet {
if let ciImage = self.initialImage {
let imageExtentSize = ciImage.extent.size
let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
//let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
let UIImage = UIImage(cgImage: cgImage)
Self._initialUIImage = UIImage
}
/*
let smallSize = CGAffineTransform(scaleX: 0.5, y: 0.5)
let smallciImage = ciImage.transformed(by: smallSize)
Self.initialImageSmall = smallciImage
*/
}
else {
Self._initialUIImage = nil
//Self.initialImageSmall = nil
}
}
}
//Used for page curl transformations
//static var initialImageSmall: CIImage?
static var _initialUIImage: UIImage? = nil
//Used for static full size display on the screen
static var initialUIImage: UIImage {
get {
if let image = Self._initialUIImage { return image }
else { return UIImage.emptyImage(size: CGSize(width: 100, height:100))}
}
}
static var futureImage: CIImage? = nil {
didSet {
if let ciImage = self.futureImage {
let imageExtentSize = ciImage.extent.size
let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
//let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
let UIImage = UIImage(cgImage: cgImage)
Self._futureUIImage = UIImage
/*
let smallSize = CGAffineTransform(scaleX: 0.5, y: 0.5)
let smallciImage = ciImage.transformed(by: smallSize)
Self.futureImageSmall = smallciImage
*/
}
}
else {
Self._futureUIImage = nil
//Self.futureImageSmall = nil
}
}
}
//Used for page curl transformations
//static var futureImageSmall: CIImage?
static var _futureUIImage: UIImage? = nil
//Used for static full size display on the screen
static var futureUIImage: UIImage {
get {
if let image = Self._futureUIImage { return image }
else { return UIImage.emptyImage(size: CGSize(width: 100, height:100))}
}
}
let ciContext = CIContext()
let backsideImage = CIImage(color: CIColor(red: 0.95, green: 0.95, blue: 0.95))
@MainActor
// This function generates the static CIImages which are the primary source for static display and small images for page curling
static func image(rect:CGRect?, view:UIView?) -> CIImage? {
guard let baseView = view
else { return nil }
let ciimage = baseView.ciimage(rect: rect, pixelsPerPoint: 2)
return ciimage
}
let progress: Double
let angle: Double // 0 .. 1/4 * Double.pi
let curlForward: Bool
func body(content: Content) -> some View {
let image = self.pageCurlImage(progress: Float(self.progress), angle:Float(self.angle))
let theView = Group {
if self.progress == 0 { content }
else {
Image(uiImage: image)
}
}
return theView
}
@MainActor
func pageCurlImage(progress:Float, angle:Float) -> UIImage {
//debugPrint("pageCurlImage, progress:", progress)
//guard let ciimage = self.curlForward ? Self.initialImageSmall : Self.futureImageSmall
guard let ciimage = self.curlForward ? Self.initialImage : Self.futureImage
else { return UIImage.emptyImage(size: CGSize(width: 300, height: 200))}
let imageExtentSize = ciimage.extent.size
let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
let aFilter = CIFilter.pageCurlWithShadowTransition()
aFilter.inputImage = ciimage
aFilter.backsideImage = self.backsideImage
aFilter.angle = Float.pi * 3.0/4.0 + angle
aFilter.radius = 1200
aFilter.time = progress
/*
aFilter.shadowSize = 0.5 // default value
aFilter.shadowAmount = 0.7 // default value
*/
if let filteredImage = aFilter.outputImage {
let context = self.ciContext
if let cgImage = context.createCGImage(filteredImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
/*
if let scaledImage = self.scaledImage(cgImage, scale: 1) {
let uiImage = UIImage(cgImage: scaledImage)
return uiImage
}
*/
let uiImage = UIImage(cgImage: cgImage)
return uiImage
}
}
let uiImage = UIImage.emptyImage(size: imageSize)
return uiImage
}
func scaledImage(_ cgImage: CGImage, scale : CGFloat) -> CGImage? {
if abs(scale - 1.0) < 0.1 { return cgImage }
// Calculate the new size
let newSize = CGSize(width: CGFloat(cgImage.width) * scale, height: CGFloat(cgImage.height) * scale)
// Create a context with double the size
guard let context = CGContext(data: nil,
width: Int(newSize.width),
height: Int(newSize.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: cgImage.bitmapInfo.rawValue) else {
return nil
}
// Draw the original image into the context at the doubled size
context.draw(cgImage, in: CGRect(origin: .zero, size: newSize))
// Retrieve the resulting image from the context
return context.makeImage()
}
}
PageCurl修饰符有几个静态变量,它们携带CIImages的initialImage和futureImage。一旦它们被设置,相应的 UIImages 就会被计算出来,并被 SwiftUI 使用。
它有三个变量定义页面卷曲的状态: 进度,角度和卷曲前向定义使用哪个图像来计算页面卷曲,初始图像或未来图像。
它有一个核心功能,可以从输入图像生成卷曲图像:
func pageCurlImage(progress:Float, angle:Float) -> UIImage
它有一个静态函数将 UIView 的部分转换为 CIImage:
@MainActor static func image(rect:CGRect?, view:UIView?) -> CIImage?
III) 卷页动画
使用新的视图函数
pageCurl(progress:Double, angle:Double, forward:Bool)
我可以将 SwiftUI 视图转换为其静态卷曲版本。我需要的是这些视图的动画系列。
我为此使用 SwiftUI .keyframeAnimator 修饰符。 KeyframeAnimators 以定义的方式逐步将参数从起始值更改为结束值。它们被触发了。
使用简单的结构来描述参数:
struct AnimatablePageCurlProperties {
var progress: Double = 0
var angle: Double = 0
}
此动画视图修改器使用 .onAppear 修改器启动自身,并在动画结束后通过将 PageCurl 静态图像重置为 nil 并将参数 isPageCurling 设置为 false 来执行清理。
//MARK: - Animation Page Curl Modifier
struct PageCurlAnimation: ViewModifier {
@State var trigger: Bool = false
let duration: Double // in seconds
let pageCurlForward: Bool
@Binding var isPageCurling: Bool // to signal that page curling finished
func body(content: Content) -> some View {
let initialCurlingState: AnimatablePageCurlProperties
let finalCurlingState: AnimatablePageCurlProperties
if self.pageCurlForward {
initialCurlingState = AnimatablePageCurlProperties()
finalCurlingState = AnimatablePageCurlProperties(progress: 2.2, angle:(Double.pi * 1.0/4.0))
}
else {
initialCurlingState = AnimatablePageCurlProperties(progress: 2.2, angle:(Double.pi * 1.0/4.0))
finalCurlingState = AnimatablePageCurlProperties()
}
return content
.keyframeAnimator(initialValue: initialCurlingState, trigger:self.trigger) { content, value in
content
.pageCurl(progress: value.progress, angle: value.angle, forward:self.pageCurlForward)
} keyframes: { _ in
KeyframeTrack(\.progress) {
LinearKeyframe(finalCurlingState.progress, duration: self.duration)
}
KeyframeTrack(\.angle) {
if self.pageCurlForward {
LinearKeyframe(finalCurlingState.angle, duration: self.duration/2.0)
}
else {
LinearKeyframe(initialCurlingState.angle, duration: self.duration/2.0)
LinearKeyframe(finalCurlingState.angle, duration: self.duration/2.0)
}
}
}
.onAppear() {
Task.detached() {@MainActor in
self.trigger.toggle()
try? await Task.sleep(for: .seconds(self.duration))
//debugPrint("Setting initialImage to nil")
PageCurl.initialImage = nil
PageCurl.futureImage = nil
self.isPageCurling = false
}
}
}
}
仍然是最后使用的PageCurlable修饰符。它是原始 SwiftUI 视图的 ZStack 和使用前一个修改器生成的 NSImage 的 SwiftUI Image 视图的相对复杂的组合。它使用几个状态参数来决定在什么情况下显示哪个ZStack。在此修改器中,如果需要,将计算来自偏移 SwiftUI 视图的initialImage 和 futureImage。
IV) PageCurlable 修饰符
struct PageCurlable : ViewModifier {
@Binding var initiatePageCurl: Bool
let curlForward: Bool
let duration: Double
let contentView: UIView?
let document: MemorizableDocument
let refreshPage: (() -> Void)
var contentViewState: ContentViewState {
get {
return self.document.mainContentViewState ?? ContentViewState()
}
}
@State var initialPageImaged: Bool = false
@State var futurePageImaged: Bool = false
@State var isPageCurling: Bool = false
func body(content: Content) -> some View {
//debugPrint("PageCurlable - isPageCurling", self.isPageCurling, "initialPageImaged:", self.initialPageImaged, "future page imaged:", self.futurePageImaged)
return GeometryReader { geometry in
VStack() {
if self.isPageCurling {
if self.curlForward {
if self.initialPageImaged {
ZStack() {
content
Image(uiImage: PageCurl.initialUIImage)
.pageCurlAnimation(duration: self.duration, forward: self.curlForward, isPageCurling: self.$isPageCurling)
}
}
else {
content
}
}
else {
if (!self.futurePageImaged) {
ZStack() {
PageCurlFutureView(content: content.documentBackground(document: self.document, contentViewState: self.contentViewState).environment(self.contentViewState), viewSize: geometry.frame(in: .global).size, futureViewRendered: self.$futurePageImaged)
Image(uiImage: PageCurl.initialUIImage)
}
}
else {
ZStack() {
Image(uiImage: PageCurl.initialUIImage)
Image(uiImage: PageCurl.futureUIImage)
.pageCurlAnimation(duration: self.duration, forward: self.curlForward, isPageCurling:self.$isPageCurling)
}
}
}
}
else {
content
}
}
.onChange(of: self.initiatePageCurl, {
Task {@MainActor in
self.pageCurlInitiation(geometry: geometry) }
})
}
}
@MainActor
func pageCurlInitiation(geometry: GeometryProxy) {
let initialImage = PageCurl.image(rect: geometry.frame(in: .global), view:self.contentView)
PageCurl.initialImage = initialImage
self.futurePageImaged = false
self.isPageCurling = true
self.initialPageImaged = true
self.refreshPage()
}
}
extension View {
func pageCurlable(initiatePageCurl: Binding<Bool>, curlForward: Bool, duration: Double, view:UIView?, document:MemorizableDocument, refreshPage: @escaping () -> Void) -> some View {
self.modifier(PageCurlable(initiatePageCurl: initiatePageCurl, curlForward: curlForward, duration: duration, contentView: view, document:document, refreshPage: refreshPage))
}
}
此修改器使用 PageCurlFutureView 结构体在向后卷曲时抓取下一页的图片进行卷曲:
struct PageCurlFutureView<Content: View>: UIViewRepresentable {
let content: Content
let viewSize: CGSize
@Binding var futureViewRendered: Bool
func makeUIView(context: Context) -> UIView {
let hostingController = UIHostingController(rootView: content)
if let uiView = hostingController.view {
uiView.isHidden = false
uiView.bounds = CGRect(origin: .zero, size: self.viewSize)
Task {@MainActor in
let futureImage = PageCurl.image(rect: nil, view:hostingController.view)
PageCurl.futureImage = futureImage
self.futureViewRendered = true
}
}
return hostingController.view
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
UIImage 上的空图像扩展:
static func emptyImage(size:CGSize, filledWithColor color: UIColor = UIColor.clear, scale: CGFloat = 0.0, opaque: Bool = false) -> UIImage {
let rect = CGRectMake(0, 0, size.width, size.height)
let imageSize = {
if size.height == 0 || size.width == 0 {
return CGSize(width: 100, height: 100)
}
else { return size }
}()
let rendererFormat = UIGraphicsImageRenderer.standardImageFormat()
rendererFormat.opaque = opaque
rendererFormat.scale = scale
let renderer = UIGraphicsImageRenderer(size: imageSize, format: rendererFormat)
let image = renderer.image { (context) in
color.set()
UIRectFill(rect)
}
return image
}
NSImage 上的emptyImage 扩展:
extension NSImage {
static func emptyImage(size:CGSize, filledWithColor color: NSColor = NSColor.clear) -> NSImage {
let theImage = NSImage(size: size, flipped: false, drawingHandler: { imageRect in
color.setFill()
imageRect.fill()
return true
})
return theImage
}
}