diff --git a/README.md b/README.md index c0bdbaa..ebe3019 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,36 @@ -# panorama +# Panorama [![pub package](https://img.shields.io/pub/v/panorama.svg)](https://pub.dev/packages/panorama) -A new Flutter package project. +A 360-degree panorama viewer. ## Getting Started -This project is a starting point for a Dart -[package](https://flutter.dev/developing-packages/), -a library module containing code that can be shared easily across -multiple Flutter or Dart projects. +Add panorama as a dependency in your pubspec.yaml file. -For help getting started with Flutter, view our -[online documentation](https://flutter.dev/docs), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +```yaml +dependencies: + panorama: ^0.0.2 +``` + +Import and add the Panorama widget to your project. + +```dart +import 'package:panorama/panorama.dart'; +... ... + +@override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Panorama( + child: Image.asset('assets/panorama.jpg'), + ), + ), + ); + } +``` + +## Screenshot + +![screenshot](https://github.com/zesage/panorama/raw/master/resource/screenshot.gif) diff --git a/example/assets/panorama.jpg b/example/assets/panorama.jpg new file mode 100644 index 0000000..004f121 Binary files /dev/null and b/example/assets/panorama.jpg differ diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index 592ceee..e8efba1 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig index 592ceee..399e934 100644 --- a/example/ios/Flutter/Release.xcconfig +++ b/example/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..b30a428 --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,90 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + generated_key_values = {} + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) do |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + generated_key_values[podname] = podpath + else + puts "Invalid plugin specification: #{line}" + end + end + generated_key_values +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Flutter Pod + + copied_flutter_dir = File.join(__dir__, 'Flutter') + copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') + copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') + unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) + # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. + # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. + # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. + + generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') + unless File.exist?(generated_xcode_build_settings_path) + raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) + cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; + + unless File.exist?(copied_framework_path) + FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) + end + unless File.exist?(copied_podspec_path) + FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) + end + end + + # Keep pod path relative so it can be checked into Podfile.lock. + pod 'Flutter', :path => 'Flutter' + + # Plugin Pods + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + system('rm -rf .symlinks') + system('mkdir -p .symlinks/plugins') + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.each do |name, path| + symlink = File.join('.symlinks', 'plugins', name) + File.symlink(path, symlink) + pod name, :path => File.join(symlink, 'ios') + end +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end +end diff --git a/example/lib/main.dart b/example/lib/main.dart index 6c4a869..1d75f9e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,7 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:panorama/panorama.dart'; +import 'package:image_picker/image_picker.dart'; void main() => runApp(MyApp()); @@ -24,13 +26,26 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { + File _imageFile; + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), - body: Panorama(), + body: Panorama( + animSpeed: 2.0, + child: _imageFile != null ? Image.file(_imageFile) : Image.asset('assets/panorama.jpg'), + ), + floatingActionButton: FloatingActionButton( + mini: true, + onPressed: () async { + _imageFile = await ImagePicker.pickImage(source: ImageSource.gallery); + setState(() {}); + }, + child: Icon(Icons.panorama), + ), ); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 6fb2a8b..db367e9 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -62,6 +62,20 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cube: + dependency: transitive + description: + name: flutter_cube + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.3" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" flutter_test: dependency: "direct dev" description: flutter @@ -74,6 +88,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3+4" matcher: dependency: transitive description: @@ -94,7 +115,7 @@ packages: path: ".." relative: true source: path - version: "0.0.1" + version: "0.0.2" path: dependency: transitive description: @@ -193,3 +214,4 @@ packages: version: "3.5.0" sdks: dart: ">=2.4.0 <3.0.0" + flutter: ">=1.12.13 <2.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d0df02b..9bbbe2a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -8,9 +8,9 @@ environment: dependencies: flutter: sdk: flutter - - panorama: + panorama: path: ../ + image_picker: ^0.6.3+4 dev_dependencies: flutter_test: @@ -19,3 +19,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/ \ No newline at end of file diff --git a/lib/panorama.dart b/lib/panorama.dart index a0faada..46d6db7 100644 --- a/lib/panorama.dart +++ b/lib/panorama.dart @@ -1,15 +1,266 @@ library panorama; +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter_cube/flutter_cube.dart'; class Panorama extends StatefulWidget { + Panorama({ + Key key, + this.latitude = 0, + this.longitude = 0, + this.zoom = 1.0, + this.minLatitude = -90.0, + this.maxLatitude = 90.0, + this.minLongitude = -180.0, + this.maxLongitude = 180.0, + this.minZoom = 1.0, + this.maxZoom = 5.0, + this.sensitivity = 1.0, + this.animSpeed = 1.0, + this.animReverse = true, + this.latSegments = 32, + this.lonSegments = 64, + this.interactive = true, + this.child, + }) : super(key: key); + + /// The initial latitude, in degrees, between -90 and 90. default to 0 + final double latitude; + + /// The initial longitude, in degrees, between -180 and 180. default to 0 + final double longitude; + + /// The initial zoom, default to 1.0. + final double zoom; + + /// The minimal latitude to show. default to -90.0 + final double minLatitude; + + /// The maximal latitude to show. default to 90.0 + final double maxLatitude; + + /// The minimal longitude to show. default to -180.0 + final double minLongitude; + + /// The maximal longitude to show. default to 180.0 + final double maxLongitude; + + /// The minimal zomm. default to 1.0 + final double minZoom; + + /// The maximal zomm. default to 5.0 + final double maxZoom; + + /// The sensitivity of the gesture. default to 1.0 + final double sensitivity; + + /// The Speed of rotation by animation. default to 1.0 + final double animSpeed; + + /// Reverse rotation when the current longitude reaches the minimal or maximum. default to true + final bool animReverse; + + /// The number of vertical divisions of the sphere. + final int latSegments; + + /// The number of horizontal divisions of the sphere. + final int lonSegments; + + /// Interact with the panorama. default to true + final bool interactive; + + /// Specify an Image(equirectangular image) widget to the panorama. + final Image child; + @override _PanoramaState createState() => _PanoramaState(); } -class _PanoramaState extends State { +class _PanoramaState extends State with SingleTickerProviderStateMixin { + Scene scene; + double latitude; + double longitude; + double latitudeDelta = 0; + double longitudeDelta = 0; + double zoomDelta = 0; + Offset _lastFocalPoint; + double _lastZoom; + double _radius = 500; + double _dampingFactor = 0.05; + double _animateDirection = 1.0; + AnimationController _controller; + + void _handleScaleStart(ScaleStartDetails details) { + _lastFocalPoint = details.localFocalPoint; + _lastZoom = null; + } + + void _handleScaleUpdate(ScaleUpdateDetails details) { + final offset = details.localFocalPoint - _lastFocalPoint; + _lastFocalPoint = details.localFocalPoint; + latitudeDelta += widget.sensitivity * 0.5 * math.pi * offset.dy / scene.camera.viewportHeight; + longitudeDelta -= widget.sensitivity * _animateDirection * 0.5 * math.pi * offset.dx / scene.camera.viewportHeight; + if (_lastZoom == null) { + _lastZoom = scene.camera.zoom; + } + zoomDelta += _lastZoom * details.scale - (scene.camera.zoom + zoomDelta); + if (!_controller.isAnimating) { + _controller.reset(); + if (widget.animSpeed != 0) { + _controller.repeat(); + } else + _controller.forward(); + } + } + + void _onSceneCreated(Scene scene) { + this.scene = scene; + scene.camera.near = 1.0; + scene.camera.far = _radius + 1.0; + scene.camera.fov = 75; + scene.camera.zoom = widget.zoom; + scene.camera.position.setFrom(Vector3(0, 0, 0.1)); + setCameraTarget(latitude, longitude); + + if (widget.child != null) { + loadImageFromProvider(widget.child.image).then((ui.Image image) { + final Mesh mesh = generateSphereMesh(radius: _radius, latSegments: widget.latSegments, lonSegments: widget.lonSegments, texture: image); + scene.world.add(Object(name: 'surface', mesh: mesh, backfaceCulling: false)); + scene.updateTexture(); + }); + } + } + + void setCameraTarget(double latitude, double longitude) { + longitude += math.pi; + scene.camera.target.x = math.cos(longitude) * math.cos(latitude) * _radius; + scene.camera.target.y = math.sin(latitude) * _radius; + scene.camera.target.z = math.sin(longitude) * math.cos(latitude) * _radius; + scene.update(); + } + + @override + void initState() { + super.initState(); + latitude = widget.latitude; + longitude = widget.longitude; + + _controller = AnimationController(duration: Duration(milliseconds: 60000), vsync: this) + ..addListener(() { + if (scene == null) return; + longitudeDelta += 0.001 * widget.animSpeed; + if (latitudeDelta.abs() < 0.001 && longitudeDelta.abs() < 0.001 && zoomDelta.abs() < 0.001) { + if (widget.animSpeed == 0 && _controller.isAnimating) _controller.stop(); + return; + } + // animate vertical rotating + latitude += latitudeDelta * _dampingFactor * widget.sensitivity; + latitudeDelta *= 1 - _dampingFactor * widget.sensitivity; + latitude = latitude.clamp(radians(math.max(-89, widget.minLatitude)), radians(math.min(89, widget.maxLatitude))); + // animate horizontal rotating + longitude += _animateDirection * longitudeDelta * _dampingFactor * widget.sensitivity; + longitudeDelta *= 1 - _dampingFactor * widget.sensitivity; + if (widget.maxLongitude - widget.minLongitude < 360) { + final double lon = longitude.clamp(radians(widget.minLongitude), radians(widget.maxLongitude)); + if (longitude != lon) { + longitude = lon; + if (widget.animSpeed != 0) { + if (widget.animReverse) { + _animateDirection *= -1.0; + } else + _controller.stop(); + } + } + } + // animate zomming + final double zoom = scene.camera.zoom + zoomDelta * _dampingFactor; + zoomDelta *= 1 - _dampingFactor; + scene.camera.zoom = zoom.clamp(widget.minZoom, widget.maxZoom); + setCameraTarget(latitude, longitude); + }); + if (widget.animSpeed != 0) _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(Panorama oldWidget) { + super.didUpdateWidget(oldWidget); + final Object surface = scene.world.find(RegExp('surface')); + if (surface == null) return; + if (widget.latSegments != oldWidget.latSegments || widget.lonSegments != oldWidget.lonSegments) { + surface.mesh = generateSphereMesh(radius: _radius, latSegments: widget.latSegments, lonSegments: widget.lonSegments, texture: surface.mesh.texture); + } + if (widget.child?.image != oldWidget.child?.image) { + loadImageFromProvider(widget.child.image).then((ui.Image image) { + surface.mesh.texture = image; + surface.mesh.textureRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + scene.updateTexture(); + }); + } + } + @override Widget build(BuildContext context) { - return Container(); + return widget.interactive + ? GestureDetector( + onScaleStart: _handleScaleStart, + onScaleUpdate: _handleScaleUpdate, + child: Cube(interactive: false, onSceneCreated: _onSceneCreated), + ) + : Cube(interactive: false, onSceneCreated: _onSceneCreated); } } + +Mesh generateSphereMesh({num radius = 1.0, int latSegments = 16, int lonSegments = 16, ui.Image texture}) { + int count = (latSegments + 1) * (lonSegments + 1); + List vertices = List(count); + List texcoords = List(count); + List indices = List(latSegments * lonSegments * 2); + + int i = 0; + for (int y = 0; y <= latSegments; ++y) { + final double v = y / latSegments; + final double sv = math.sin(v * math.pi); + final double cv = math.cos(v * math.pi); + for (int x = 0; x <= lonSegments; ++x) { + final double u = x / lonSegments; + vertices[i] = Vector3(radius * math.cos(u * math.pi * 2.0) * sv, radius * cv, radius * math.sin(u * math.pi * 2.0) * sv); + texcoords[i] = Offset(u, 1.0 - v); + i++; + } + } + + i = 0; + for (int y = 0; y < latSegments; ++y) { + final int base1 = (lonSegments + 1) * y; + final int base2 = (lonSegments + 1) * (y + 1); + for (int x = 0; x < lonSegments; ++x) { + indices[i++] = Polygon(base1 + x, base1 + x + 1, base2 + x); + indices[i++] = Polygon(base1 + x + 1, base2 + x + 1, base2 + x); + } + } + + final Mesh mesh = Mesh(vertices: vertices, texcoords: texcoords, indices: indices, texture: texture); + return mesh; +} + +/// Get ui.Image from ImageProvider +Future loadImageFromProvider(ImageProvider provider) async { + final Completer completer = Completer(); + final ImageStream imageStream = provider.resolve(ImageConfiguration()); + ImageStreamListener listener; + listener = ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) { + completer.complete(imageInfo.image); + imageStream.removeListener(listener); + }); + imageStream.addListener(listener); + return completer.future; +} diff --git a/pubspec.lock b/pubspec.lock index 67a7df1..66ab63a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -62,6 +62,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cube: + dependency: "direct main" + description: + name: flutter_cube + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.3" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 8304eb4..468edb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: panorama -description: A new Flutter package project. -version: 0.0.1 +description: Panorama -- A 360-degree panorama viewer. +version: 0.0.2 homepage: https://github.com/zesage/panorama environment: @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_cube: ^0.0.3 dev_dependencies: flutter_test: diff --git a/resource/screenshot.gif b/resource/screenshot.gif new file mode 100644 index 0000000..21ce807 Binary files /dev/null and b/resource/screenshot.gif differ