Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

scaling a centered div in a scrollable container

I'm trying to do a scale transformation on a div that is centered on a scrollable container div.

The trick i'm using to reflect the new div size after transformation, is using a wrapper and setting the new width/height to it so the parent can show the scrollbars correctly.

.container {
    position: relative;
    border: 3px solid red;
    width: 600px; height: 400px;
    background-color: blue;
    overflow-x: scroll; overflow-y: scroll;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
}
.wrapper {
    order: 1;
    background-color: yellow;
}
.content-outer {
    width: 300px;
    height: 200px;
    transform-origin: 50% 50%;
    /*transform-origin: 0 0;*/
}
.content-outer.animatted {
    animation: scaleAnimation 1s ease-in forwards;
}
.content-outer.animatted2 {
    animation: scaleAnimation2 1s ease-in forwards;
}
.content-inner {
    width: 300px;
    height: 200px;
    background: linear-gradient(to right, red, white);
}

if the transformation origin was 0,0 the div is centered without animation jumps but the scrollbars are not correct. if the origin was in the middle both the div location and scrollbars are missed up

I have tried two ways to do the centering, using flexbox (http://jsfiddle.net/r3jqyjLz/1/) and using negative margins (http://jsfiddle.net/roLf5tph/1/).

Is there a better way to do this ?

like image 312
Ahmed Kotb Avatar asked Jul 05 '15 01:07

Ahmed Kotb


5 Answers

Is this what you are after?

I used CSS transitions for the scaling animation.

#centered {
    background-image: linear-gradient(to bottom,yellow,red);
    height: 200px;
    transform: scale(1);
    transition: transform 1s ease-out;
    width: 200px;
}

#scrollable {
    align-items: center;
    border: 1px solid red;
    display: flex;
    height: 300px;
    justify-content: center;
    overflow: auto;
    width: 300px;
}

Update #1

How about this one?

  • Using old school absolute centering (position absolute)
  • I see your point about flexbox. It seems that flexbox centering has some limitations when the centered element is larger than its container.
like image 122
Matthew King Avatar answered Nov 16 '22 14:11

Matthew King


I am assuming that parts of the problem you are having can be summarized as:

  1. You have a complex structure in your inner div which you want to scale as a group. (If it were a single element, this would've been easy with a matrix).
  2. When the inner div is scaled beyond the bounds of the container, you don't get scrollbars without controlling the width/height.
  3. You want the inner div to remain centered and at the same time, scrollbars should reflect the correct position.

With this, there are two (three actually) easy options.

Option 1:

Using the same markup in the question, you could keep the div centered when the scale factor is below 1. And for scale factors above 1, you change it to top-left. the overflow: auto on the container will take care of the scroll on its own because the div being scaled (via transform) is wrapped inside of another div. You don't really need Javascript for this.

This solves your problems 1 and 2.

Fiddle 1: http://jsfiddle.net/abhitalks/c0okhznc/

Snippet 1:

* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: auto;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform-origin: top left;
    transform: translate(-50%, -50%); 
    transition: all 1s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
}
.inner { width:50px; height: 50px; background-color: green; }

#s0:checked ~ div#container .wrapper { transform: scale(0.5) translate(-50%, -50%); }
#s1:checked ~ div#container .wrapper { transform: scale(0.75) translate(-50%, -50%); }
#s2:checked ~ div#container .wrapper { transform: scale(1) translate(-50%, -50%); }
#s3:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(1.5) translate(0%, 0%); }
#s4:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(2) ; }
#s5:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(3) ; }
<input id="s0" name="scale" data-scale="0.5" type="radio" />Scale 0.5
<input id="s1" name="scale" data-scale="0.75" type="radio" />Scale 0.75
<input id="s2" name="scale" data-scale="1" type="radio" checked />Scale 1
<input id="s3" name="scale" data-scale="1.5" type="radio" />Scale 1.5
<input id="s4" name="scale" data-scale="2" type="radio" />Scale 2
<input id="s5" name="scale" data-scale="3" type="radio" />Scale 3
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

But this creates another problem. The overflow:auto will cause a jump/flicker when the div is scaled beyond the container. This can be easily solved by making it overflow:scroll to show the scrollbars at all times (the way you are doing it already). Although, it works like a charm in Firefox, Chrome falters here and doesn't update the scrollbar position. The trick here is to use Javascript to force a reflow by changing the overflow to auto once your scaling completes. So you need to delay it a bit.

Fiddle 2: http://jsfiddle.net/abhitalks/u7sfef0b/

Snippet 2:

$("input").on("click", function() {
    var scroll = 'scroll', 
        scale = +($(this).data("scale"));
    if (scale > 1) { scroll = 'auto'; }
    setTimeout(fixScroll, 300, scroll);
});

function fixScroll(scroll) { $("#container").css({ overflow: scroll }); }
* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: scroll;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform-origin: top left;
    transform: translate(-50%, -50%); 
    transition: all 1s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
}
.inner { width:50px; height: 50px; background-color: green; }

#s0:checked ~ div#container .wrapper { transform: scale(0.5) translate(-50%, -50%); }
#s1:checked ~ div#container .wrapper { transform: scale(0.75) translate(-50%, -50%); }
#s2:checked ~ div#container .wrapper { transform: scale(1) translate(-50%, -50%); }
#s3:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(1.5) translate(0%, 0%); }
#s4:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(2) ; }
#s5:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(3) ; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<input id="s0" name="scale" data-scale="0.5" type="radio" />Scale 0.5
<input id="s1" name="scale" data-scale="0.75" type="radio" />Scale 0.75
<input id="s2" name="scale" data-scale="1" type="radio" checked />Scale 1
<input id="s3" name="scale" data-scale="1.5" type="radio" />Scale 1.5
<input id="s4" name="scale" data-scale="2" type="radio" />Scale 2
<input id="s5" name="scale" data-scale="3" type="radio" />Scale 3
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

Option 2:

In order to solve problem 3 of keeping the div centered and at the same time maintaining the correct scrollbar position, you have to fallback on Javascript.

The principle remains the same that for scale factors above 1 you need to reset the top-left and translate positions. You would also need to recalc the scaled width/height and then re-assign that to your wrapper div. Then setting the scrollTop and scrollLeft on the container will be as easy as just getting the difference of the wrapper div and the container div.

This solves your problems 1, 2, and 3.

Fiddle 3: http://jsfiddle.net/abhitalks/rheo6o7p/1/

Snippet 3:

var $container = $("#container"), 
    $wrap = $("#wrapper"), 
    $elem = $("#box"), 
    originalWidth = 300, originalHeight = 200;

$("input").on("click", function() {
    var factor = +(this.value);
    scaler(factor);
});

function scaler(factor) {
    var newWidth = originalWidth * factor,
        newHeight = originalHeight * factor;
    $wrap.width(newWidth); $wrap.height(newHeight);
    if (factor > 1) {
        $wrap.css({ left: 0, top: 0, transform: 'translate(0,0)' }); 
        $elem.css({ transform: 'scale(' + factor + ')' }); 
        setTimeout(setScroll, 400);
    } else {
        $elem.css({ transform: 'scale(' + factor + ') ' });        
        $wrap.css({ left: '50%', top: '50%', transform: 'translate(-50%, -50%)' });
    }
}

function setScroll() {
    var horizontal, vertical;
    horizontal = ($wrap.width() - $container.width()) / 2;
    vertical = ($wrap.height() - $container.height()) / 2;
    $container.stop().animate({scrollTop: vertical, scrollLeft: horizontal}, 500);
}
* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: scroll;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%); 
    transition: all 0.5s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
    transform-origin: top left;
    transition: all 0.5s;
}
.inner { width:50px; height: 50px; background-color: green; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
<label for="slider1">Scale: </label>
<input id="slider1" type="range" min="0.5" max="3" value="1" step="0.25" list="datalist" onchange="scaleValue.value=value" />
<output for="slider1" id="scaleValue">1</output>
<datalist id="datalist">
	<option>0.5</option>
	<option>0.75</option>
	<option>1</option>
    <option>1.5</option>
    <option>2</option>
    <option>3</option>
</datalist>
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

All scripts tested with IE-11, GC-43, and FF-38

.

like image 26
Abhitalks Avatar answered Nov 16 '22 14:11

Abhitalks


The hard part of this problem is that, in the middle of the zoom in process, you need to change from a model (where you have still margins) to another model where you need to expand the size of the container.

Since this happens in the middle of the transform, it's difficult to handle with a single property transform

The way that I have found to solve this is to change the min-height and min-width properties. This will give the posibility to auto detect this point and handle it gracefully

I am keeping in JS only the basic functionality, everything else is done in CSS

function setZoom3() {
    var ele = document.getElementById("base");
    ele.className = "zoom3";
}

function setZoom1() {
    var ele = document.getElementById("base");
    ele.className = "";
}

function setZoom05() {
    var ele = document.getElementById("base");
    ele.className = "zoom05";
}
.container {
    width: 300px;
    height: 300px;
    overflow: scroll;
    position: relative;
}

#base {
    width: 300px;
    height: 300px;
    top: 0px;
    left: 0px;
    position: absolute;
    min-width: 300px;
    min-height: 300px;
    transition: min-width 5s, min-height 5s;
}

.inner {
    width: 200px;
    height: 200px;
    background-color: lightgreen;
    background-image: linear-gradient(-45deg, red 5%, yellow 5%, green 95%, blue 95%);
    top: 50%;
    left: 50%;
    position: absolute;
    transform: translate(-50%, -50%); 
    transform-origin: top left;
    transition: transform 5s;
}

#base.zoom3 {
    min-width: 600px;
    min-height: 600px;
}
.zoom3 .inner {
    transform: scale(3) translate(-50%, -50%);
}

.zoom05 .inner {
    transform: scale(0.5) translate(-50%, -50%);
}

.trick {
    width: 20px;
    height: 20px;
    background-color: red;
    position: absolute;
    
    transform: translate(0px);
}
<div class="container">
    <div id="base">
        <div class="inner"></div>
    </div>
</div>
<button onclick="setZoom3();">zoom 3</button>
<button onclick="setZoom1();">zoom 1</button>
<button onclick="setZoom05();">zoom 0.5</button>

The only issue left would be to set the scroll position, just in case you want it to keep in the center

like image 3
vals Avatar answered Nov 16 '22 14:11

vals


I've managed to solve it with a touch of logic. I've used GSAP for the animation and updates, but you could easily get it working with vanilla JS:

http://codepen.io/theprojectsomething/pen/JdZWLV

... Because of the need to scroll the parent div to keep the element centred (and no way to animate the scroll) you're not going to be able to get a smooth transition with CSS alone.

Note: Even without the scroll, a pure CSS transition seems to have trouble syncing (the offset, whether top/left, margin, or translate, is always catching up with the scale) ... this may be due to sub-pixel positioning? Someone else may be able to provide further insight here.

Full code from Codepen:

var $inner = document.querySelector('aside'),
    $outer = document.querySelector('main'),
    $anim = document.querySelector('[type="checkbox"]'),
    $range = document.querySelector('[type="range"]'),
    data = {
      width: $outer.clientWidth,
      value: 1
    };

$range.addEventListener('input', slide, false);

function slide () {
  $anim.checked ? animate(this.value) : transform(this.value);
}

function animate (value) {
  TweenLite.to(data, 0.4, {
    value: value,
    onUpdate: transform
  });
}

function transform (value) {
  if( !isNaN(value) ) data.value = value;
  
  var val = Math.sqrt(data.value),
      offset = Math.max(1 - val, 0)*0.5,
      scroll = (val - 1)*data.width*0.5;

  TweenLite.set($inner, {
    scale: val,
    x: offset*100 + "%",
    y: offset*100 + "%"
  });
  
  TweenLite.set($outer, {
    scrollLeft: scroll,
    scrollTop: scroll
  });
}

window.onresize = function (){
  data.width = $outer.clientWidth;
  transform(data.value);
};
main, aside:before, footer {
  position: absolute;
  top: 50%;
  left: 50%;
  -webkit-transform: translate(-50%, -50%);
      -ms-transform: translate(-50%, -50%);
          transform: translate(-50%, -50%);
}

main {
  width: 50vh;
  height: 50vh;
  min-width: 190px;
  min-height: 190px;
  background: black;
  overflow: scroll;
  box-sizing: border-box;
}

aside {
  position: relative;
  width: 100%;
  height: 100%;
  border: 1vh solid rgba(0, 0, 0, 0.5);
  background-image: -webkit-linear-gradient(top, yellow, red);
  background-image: linear-gradient(to bottom, yellow, red);
  box-sizing: border-box;
  -webkit-transform-origin: 0 0;
      -ms-transform-origin: 0 0;
          transform-origin: 0 0;
}
aside:before {
  content: "";
  background: black;
  border-radius: 50%;
  width: 10%;
  height: 10%;
  display: block;
  opacity: 0.5;
}

input {
  display: block;
  margin: 3em auto;
}
input:after {
  content: attr(name);
  color: white;
  text-transform: uppercase;
  position: relative;
  left: 2em;
  display: block;
  line-height: 0.9em;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.16.1/TweenMax.min.js"></script>
<main>
  <aside></aside>
</main>
<footer>
  <input type="checkbox" name="animate" checked>
  <input type="range" min="0.1" step="0.005" max="3">
</footer>
like image 2
som Avatar answered Nov 16 '22 14:11

som


Matthew King's fiddle was close to being correct.
The only thing that needs fixing is the div's offset. If your inner div is exceeding the boundaries of the scrollable div (negative offset) you need to set the offset to 0. Else you want the div to be centered.

Centering is however not that trivial. You are zooming the div's background with css, hence you are not affecting the actual width and height of your div and need to parse the scale matrix to calculate it's backgrounds dimension.

This solution works for me: https://jsfiddle.net/6e2g6vzt/11/
(at least for FF on Ubuntu, did not test on other browsers yet)

It is basically just one function to set the new offset, you don't even have to call it manually due to this line of code

$("#centered").bind("transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd", setNewOffset);

which calls the setNewOffset function after the transformation is completed.
As you can see the image 'jumps' to it's correct position after the transformation, maybe you want to add a smooth effect to cover that but i just wanted to show you how to get the correct offset.

Have a look at jQuery's documentation to learn more about .offset()


Credits:
Thanks to Jim Jeffers for the nice callback after transistions
https://stackoverflow.com/a/9255507/3586288
Thanks to Lea Verou for the great regex to parse the scale matrix
https://stackoverflow.com/a/5604199/3586288

like image 1
Tim Hallyburton Avatar answered Nov 16 '22 16:11

Tim Hallyburton