本のようなUIを作る需要があり、作ってみたらいい感じになったので共有。
1. 使用例
本とそれを構成するページの表裏をDSL的に記述していく。
いい感じ。
struct BookView: View {
var body: some View {
NavigationStack {
VStack {
Book(config: .init()) {
Page {
fistFront
} back: {
firstBack
}
Page {
secondFront
} back: {
secondBack
}
}
}
.padding(15)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.15))
.navigationTitle("Book View")
}
}
var fistFront: some View {
VStack {
Text("Book Title")
.font(.largeTitle)
Spacer()
Text("Author Name").bold()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.cyan)
}
var firstBack: some View {
VStack {
Text("1st back")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
}
var secondFront: some View {
VStack {
Text("2nd front")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
}
var secondBack: some View {
VStack {
Text("last page")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
}
}
2. Bookの実装
@resultBuilder
を使っているのが肝。
@resultBuilder
struct PageBuilder {
static func buildBlock(_ pages: Page...) -> [Page] {
return pages
}
}
struct Book: View, Animatable {
var config: Config = .init()
var pages: [Page]
@State private var offset: CGFloat = 0
init(config: Config, @PageBuilder content: () -> [Page]) {
self.config = config
self.pages = content()
}
var body: some View {
GeometryReader {
let size = $0.size
ZStack {
ForEach(Array(pages.enumerated()), id: \.element.id) { index, page in
OpenablePage(bookOffset: $offset, size: size, page: page, index: Double(index))
}
}
}
.frame(width: config.width, height: config.height)
.offset(x: offset)
}
struct Config {
var width: CGFloat = 350
var height: CGFloat = 500
}
}
Pageの実装
scaleEffect(x: -1)
で裏面のViewを反転させ、overlay
で重ねる。
rotation3DEffect
を使い、90度回転したところで裏を表示させることでフリップする感じを演出。
縦に長いiPhoneの画面をフルに活用するために、画面には両開きで表示するのではなく、1ページだけを表示する。
offset
を使って、本自体をスライド→ページをめくる を繰り返す。
struct Page: Identifiable {
var id = UUID()
var front: AnyView
var back: AnyView
init<Front: View, Back: View>(
@ViewBuilder front: @escaping () -> Front,
@ViewBuilder back: @escaping () -> Back
) {
self.front = AnyView(front())
self.back = AnyView(back())
}
}
struct OpenablePage: View {
@Binding var bookOffset: CGFloat
let size: CGSize
let page: Page
let index: Double
@State private var progress: Double = 0.0
@State private var animationInProgress = false
var body: some View {
FlipPage(progress: progress, size: size, front: page.front, back: page.back, index: index)
.gesture(
DragGesture()
.onChanged { value in
if animationInProgress { return }
let translationWidth = value.translation.width
let isDraggingToLeft = translationWidth < 0
let frontIsDisplayed = bookOffset == 0
let threshold = size.width / 3
if isDraggingToLeft && -translationWidth > threshold {
animationInProgress = true
withAnimation(.easeOut(duration: 1)) {
if frontIsDisplayed {
bookOffset = size.width
progress = 1.0
} else {
bookOffset = 0
}
} completion: {
animationInProgress = false
}
}
if !isDraggingToLeft && translationWidth > threshold {
animationInProgress = true
withAnimation(.easeIn(duration: 1)) {
if frontIsDisplayed {
bookOffset = size.width
} else {
bookOffset = 0
progress = 0.0
}
} completion: {
animationInProgress = false
}
}
}
)
}
struct FlipPage: View, Animatable {
var progress: Double
let size: CGSize
let front: AnyView
let back: AnyView
let index: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
var body: some View {
let rotation = -180 * progress
front
.frame(width: size.width, height: size.height)
.overlay {
if rotation < -90 {
back
.frame(width: size.width, height: size.height)
.scaleEffect(x: -1)
.transition(.identity)
}
}
.roundTrailingCorner(10)
.shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 0)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0),
anchor: .leading,
perspective: 0.3
)
.zIndex(progress == 0 ? -index: index)
}
}
}
fileprivate extension View {
func roundTrailingCorner(_ radius: CGFloat) -> some View {
modifier(RoundTrailingCorner(radius: radius))
}
}
fileprivate struct RoundTrailingCorner: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
content
.clipShape(
.rect(
topLeadingRadius: 0,
bottomLeadingRadius: 0,
bottomTrailingRadius: radius,
topTrailingRadius: radius
)
)
}
}